diff --git a/actions-expressions/.npmrc b/actions-expressions/.npmrc new file mode 100644 index 0000000..cd5193e --- /dev/null +++ b/actions-expressions/.npmrc @@ -0,0 +1,2 @@ +@github:registry=https://npm.pkg.github.com + diff --git a/actions-expressions/jest.config.js b/actions-expressions/jest.config.js new file mode 100755 index 0000000..1382fb0 --- /dev/null +++ b/actions-expressions/jest.config.js @@ -0,0 +1,16 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: "ts-jest/presets/default-esm", + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, + moduleFileExtensions: ["ts", "js"], +}; diff --git a/actions-expressions/package.json b/actions-expressions/package.json new file mode 100755 index 0000000..8439f42 --- /dev/null +++ b/actions-expressions/package.json @@ -0,0 +1,51 @@ +{ + "name": "@github/actions-expressions", + "version": "0.0.9", + "license": "MIT", + "type": "module", + "source": "./src/index.ts", + "exports": { + ".": { + "import": "./dist/index.js" + }, + "./*": { + "import": "./dist/*.js" + } + }, + "typesVersions": { + "*": { + ".": [ + "dist/index.d.ts" + ], + "*": [ + "dist/*.d.ts" + ] + } + }, + "repository": { + "type": "git", + "url": "https://github.com/github/actions-expressions/" + }, + "scripts": { + "build": "tsc --build tsconfig.build.json", + "clean": "rimraf dist", + "prepublishOnly": "npm run build && npm run test", + "test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest", + "test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch", + "watch": "tsc --build tsconfig.build.json --watch" + }, + "engines": { + "node": ">= 16" + }, + "files": [ + "dist/**/*" + ], + "devDependencies": { + "@types/jest": "^29.0.3", + "jest": "^29.0.3", + "prettier": "^2.7.1", + "rimraf": "^3.0.2", + "ts-jest": "^29.0.3", + "typescript": "^4.7.4" + } +} \ No newline at end of file diff --git a/actions-expressions/script/build b/actions-expressions/script/build new file mode 100755 index 0000000..c4ac22e --- /dev/null +++ b/actions-expressions/script/build @@ -0,0 +1,3 @@ +#!/bin/bash + +npm run build \ No newline at end of file diff --git a/actions-expressions/script/test b/actions-expressions/script/test new file mode 100755 index 0000000..b004630 --- /dev/null +++ b/actions-expressions/script/test @@ -0,0 +1,3 @@ +#!/bin/bash + +npm test \ No newline at end of file diff --git a/actions-expressions/src/ast.ts b/actions-expressions/src/ast.ts new file mode 100644 index 0000000..84808e9 --- /dev/null +++ b/actions-expressions/src/ast.ts @@ -0,0 +1,103 @@ +import { ExpressionData } from "./data"; +import { Token } from "./lexer"; + +export interface ExprVisitor { + visitLiteral(literal: Literal): R; + visitUnary(unary: Unary): R; + visitBinary(binary: Binary): R; + visitLogical(binary: Logical): R; + visitGrouping(grouping: Grouping): R; + visitContextAccess(contextAccess: ContextAccess): R; + visitIndexAccess(indexAccess: IndexAccess): R; + visitFunctionCall(functionCall: FunctionCall): R; +} + +export abstract class Expr { + abstract accept(v: ExprVisitor): R; +} + +export class Literal extends Expr { + constructor(public literal: ExpressionData) { + super(); + } + + accept(v: ExprVisitor): R { + return v.visitLiteral(this); + } +} + +export class Unary extends Expr { + constructor(public operator: Token, public expr: Expr) { + super(); + } + + accept(v: ExprVisitor): R { + return v.visitUnary(this); + } +} + +export class FunctionCall extends Expr { + constructor(public functionName: Token, public args: Expr[]) { + super(); + } + + accept(v: ExprVisitor): R { + return v.visitFunctionCall(this); + } +} + +export class Binary extends Expr { + constructor(public left: Expr, public operator: Token, public right: Expr) { + super(); + } + + accept(v: ExprVisitor): R { + return v.visitBinary(this); + } +} + +export class Logical extends Expr { + constructor(public operator: Token, public args: Expr[]) { + super(); + } + + accept(v: ExprVisitor): R { + return v.visitLogical(this); + } +} + +export class Grouping extends Expr { + constructor(public group: Expr) { + super(); + } + + accept(v: ExprVisitor): R { + return v.visitGrouping(this); + } +} + +export class ContextAccess extends Expr { + constructor(public name: Token) { + super(); + } + + accept(v: ExprVisitor): R { + return v.visitContextAccess(this); + } +} + +export class IndexAccess extends Expr { + constructor(public expr: Expr, public index: Expr) { + super(); + } + + accept(v: ExprVisitor): R { + return v.visitIndexAccess(this); + } +} + +export class Star extends Expr { + accept(v: ExprVisitor): R { + throw new Error("Method not implemented."); + } +} diff --git a/actions-expressions/src/completion.test.ts b/actions-expressions/src/completion.test.ts new file mode 100644 index 0000000..b95f839 --- /dev/null +++ b/actions-expressions/src/completion.test.ts @@ -0,0 +1,156 @@ +import { complete, CompletionItem, trimTokenVector } from "./completion"; +import { BooleanData } from "./data/boolean"; +import { Dictionary } from "./data/dictionary"; +import { StringData } from "./data/string"; +import { FunctionInfo } from "./funcs/info"; +import { Lexer, TokenType } from "./lexer"; + +const testContext = new Dictionary( + { + key: "env", + value: new Dictionary( + { + key: "FOO", + value: new StringData(""), + }, + { + key: "BAR_TEST", + value: new StringData(""), + } + ), + }, + { + key: "secrets", + value: new Dictionary({ + key: "AWS_TOKEN", + value: new BooleanData(true), + }), + }, + { + key: "hashFiles(1,255)", + value: new Dictionary(), + } +); + +const testFunctions: FunctionInfo[] = []; + +const testComplete = (input: string): CompletionItem[] => { + const pos = input.indexOf("|"); + input = input.replace("|", ""); + + const results = complete( + input.slice(0, pos >= 0 ? pos : input.length), + testContext, + testFunctions + ); + + return results; +}; + +function completionItems(...labels: string[]): CompletionItem[] { + return labels.map((label) => ({ label, function: false })); +} + +describe("auto-complete", () => { + describe("top-level", () => { + it("includes built-in functions", () => { + const expected: CompletionItem = { + label: "toJson", + description: + "`toJSON(value)`\n\nReturns a pretty-print JSON representation of `value`. You can use this function to debug the information provided in contexts.", + function: true, + }; + expect(testComplete("to")).toContainEqual(expected); + expect(testComplete("toJs")).toContainEqual(expected); + expect(testComplete("1 == toJS")).toContainEqual(expected); + expect(testComplete("toJS| == 1")).toContainEqual(expected); + }); + + it("removes parentheses from passed in function context", () => { + expect(testComplete("|")).toContainEqual({ + label: "hashFiles", + function: true, + }); + }); + }); + + describe("for contexts", () => { + it("provides suggestions for env", () => { + const expected = completionItems("BAR_TEST", "FOO"); + expect(testComplete("env.X")).toEqual(expected); + expect(testComplete("1 == env.F")).toEqual(expected); + expect(testComplete("env.")).toEqual(expected); + expect(testComplete("env.FOO")).toEqual(expected); + }); + + it("provides suggestions for secrets", () => { + const expected = completionItems("AWS_TOKEN"); + + expect(testComplete("secrets.A")).toEqual(expected); + expect(testComplete("1 == secrets.F")).toEqual(expected); + expect(testComplete("toJSON(secrets.")).toEqual(expected); + }); + + it("provides suggestions for contexts in function call", () => { + expect(testComplete("toJSON(env.|)")).toEqual( + completionItems("BAR_TEST", "FOO") + ); + }); + }); +}); + +describe("trimTokenVector", () => { + test.each<{ + input: string; + expected: TokenType[]; + }>([ + { + input: "env.", + expected: [TokenType.IDENTIFIER, TokenType.DOT, TokenType.EOF], + }, + { + input: "github.act", + expected: [ + TokenType.IDENTIFIER, + TokenType.DOT, + TokenType.IDENTIFIER, + TokenType.EOF, + ], + }, + { + input: "1 == github.act", + expected: [ + TokenType.IDENTIFIER, + TokenType.DOT, + TokenType.IDENTIFIER, + TokenType.EOF, + ], + }, + { + input: "github.mona == github.act", + expected: [ + TokenType.IDENTIFIER, + TokenType.DOT, + TokenType.IDENTIFIER, + TokenType.EOF, + ], + }, + { + input: "github['test'].", + expected: [ + TokenType.IDENTIFIER, + TokenType.LEFT_BRACKET, + TokenType.STRING, + TokenType.RIGHT_BRACKET, + TokenType.DOT, + TokenType.EOF, + ], + }, + ])("$input", ({ input, expected }) => { + const l = new Lexer(input); + const lr = l.lex(); + + const tv = trimTokenVector(lr.tokens); + expect(tv.map((x) => x.type)).toEqual(expected); + }); +}); diff --git a/actions-expressions/src/completion.ts b/actions-expressions/src/completion.ts new file mode 100644 index 0000000..35b62ae --- /dev/null +++ b/actions-expressions/src/completion.ts @@ -0,0 +1,147 @@ +import { Dictionary, isDictionary } from "./data/dictionary"; +import { ExpressionData } from "./data/expressiondata"; +import { Evaluator } from "./evaluator"; +import { wellKnownFunctions } from "./funcs"; +import { FunctionInfo } from "./funcs/info"; +import { Lexer, Token, TokenType } from "./lexer"; +import { Parser } from "./parser"; + +export type CompletionItem = { + label: string; + description?: string; + function: boolean; +}; + +// Complete returns a list of completion items for the given expression. +// +// The main functionality is auto-completing functions and context access: +// We can only provide assistance if the input is in one of the following forms (with | denoting the cursor position): +// - context.path.inp| or context.path['inp| -- auto-complete context access +// - context.path.| or context.path['| -- auto-complete context access +// - toJS| -- auto-complete function call or top-level +// - | -- auto-complete function call or top-level context access +export function complete( + input: string, + context: Dictionary, + extensionFunctions: FunctionInfo[] +): CompletionItem[] { + // Lex + const lexer = new Lexer(input); + const lexResult = lexer.lex(); + + // Find interesting part of the tokenVector. For example, for an expression like `github.actor == env.actor.log|`, we are + // only interested in the `env.actor.log` part for auto-completion + const tokenInputVector = trimTokenVector(lexResult.tokens); + + // Start by skipping the EOF token + let tokenIdx = tokenInputVector.length - 2; + + if (tokenIdx >= 0) { + switch (tokenInputVector[tokenIdx].type) { + // If there is a (partial) identifier under the cursor, ignore that + case TokenType.IDENTIFIER: + tokenIdx--; + break; + + case TokenType.STRING: + // TODO: Support string for `context.name['test|` + return []; + } + } + + if (tokenIdx < 0) { + // Vector only contains the EOF token. Suggest functions and root context access + const result = contextKeys(context); + + // Merge with functions + result.push(...functionItems(extensionFunctions)); + + return result; + } + + // Determine path that led to the last token + // Use parser & evaluator to determine context to complete. + const pathTokenVector = tokenInputVector.slice(0, tokenIdx); + + // Include the original EOF token to make the parser happy + pathTokenVector.push(tokenInputVector[tokenInputVector.length - 1]); + + const p = new Parser( + pathTokenVector, + context.pairs().map((x) => x.key), + extensionFunctions + ); + const expr = p.parse(); + + const ev = new Evaluator(expr, context); + const result = ev.evaluate(); + + return contextKeys(result); +} + +function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] { + const result: CompletionItem[] = []; + + for (const fdef of [ + ...Object.values(wellKnownFunctions), + ...extensionFunctions, + ]) { + result.push({ + label: fdef.name, + description: fdef.description, + function: true, + }); + } + + // Sort functions + result.sort((a, b) => a.label.localeCompare(b.label)); + + return result; +} + +function contextKeys(context: ExpressionData): CompletionItem[] { + if (isDictionary(context)) { + return ( + context + .pairs() + .map((x) => completionItemFromContext(x.key)) + // Sort contexts + .sort((a, b) => a.label.localeCompare(b.label)) + ); + } + + return []; +} + +function completionItemFromContext(context: string): CompletionItem { + const parenIndex = context.indexOf("("); + const isFunc = parenIndex >= 0 && context.indexOf(")") >= 0; + + return { + label: isFunc ? context.substring(0, parenIndex) : context, + function: isFunc, + }; +} + +export function trimTokenVector(tokenVector: Token[]): Token[] { + let tokenIdx = tokenVector.length; + + while (tokenIdx > 0) { + const token = tokenVector[tokenIdx - 1]; + switch (token.type) { + case TokenType.IDENTIFIER: + case TokenType.DOT: + case TokenType.EOF: + case TokenType.LEFT_BRACKET: + case TokenType.RIGHT_BRACKET: + case TokenType.STRING: + tokenIdx--; + continue; + } + + break; + } + + // Only keep the part of the token vector we're interested in + return tokenVector.slice(tokenIdx); +} diff --git a/actions-expressions/src/data/array.ts b/actions-expressions/src/data/array.ts new file mode 100644 index 0000000..d36fba7 --- /dev/null +++ b/actions-expressions/src/data/array.ts @@ -0,0 +1,40 @@ +import { + ExpressionData, + ExpressionDataInterface, + Kind, + kindStr, +} from "./expressiondata"; + +export class Array implements ExpressionDataInterface { + private v: ExpressionData[] = []; + + constructor(...data: ExpressionData[]) { + for (const d of data) { + this.add(d); + } + } + + public readonly kind = Kind.Array; + + public primitive = false; + + coerceString(): string { + return kindStr(this.kind); + } + + number(): number { + return NaN; + } + + add(value: ExpressionData) { + this.v.push(value); + } + + get(index: number): ExpressionData { + return this.v[index]; + } + + values(): ExpressionData[] { + return this.v; + } +} diff --git a/actions-expressions/src/data/boolean.ts b/actions-expressions/src/data/boolean.ts new file mode 100644 index 0000000..cc3db20 --- /dev/null +++ b/actions-expressions/src/data/boolean.ts @@ -0,0 +1,25 @@ +import { ExpressionDataInterface, Kind } from "./expressiondata"; + +export class BooleanData implements ExpressionDataInterface { + constructor(public readonly value: boolean) {} + + public readonly kind = Kind.Boolean; + + public primitive = true; + + coerceString(): string { + if (this.value) { + return "true"; + } + + return "false"; + } + + number(): number { + if (this.value) { + return 1; + } + + return 0; + } +} diff --git a/actions-expressions/src/data/dictionary.test.ts b/actions-expressions/src/data/dictionary.test.ts new file mode 100644 index 0000000..f3c5471 --- /dev/null +++ b/actions-expressions/src/data/dictionary.test.ts @@ -0,0 +1,11 @@ +import { Dictionary } from "./dictionary"; +import { StringData } from "./string"; + +describe("dictionary", () => { + it("pairs contains all values", () => { + const d = new Dictionary(); + d.add("ABC", new StringData("val")); + + expect(d.pairs()).toEqual([{ key: "ABC", value: new StringData("val") }]); + }); +}); diff --git a/actions-expressions/src/data/dictionary.ts b/actions-expressions/src/data/dictionary.ts new file mode 100644 index 0000000..43bf175 --- /dev/null +++ b/actions-expressions/src/data/dictionary.ts @@ -0,0 +1,68 @@ +import { + ExpressionData, + ExpressionDataInterface, + Kind, + kindStr, + Pair, +} from "./expressiondata"; + +export class Dictionary implements ExpressionDataInterface { + private keys: string[] = []; + private v: ExpressionData[] = []; + private indexMap: { [key: string]: number } = {}; + + constructor(...pairs: Pair[]) { + for (const p of pairs) { + this.add(p.key, p.value); + } + } + + public readonly kind = Kind.Dictionary; + + public primitive = false; + + coerceString(): string { + return kindStr(this.kind); + } + + number(): number { + return NaN; + } + + add(key: string, value: ExpressionData) { + if (this.indexMap[key.toLowerCase()]) { + return; + } + + this.keys.push(key); + this.v.push(value); + this.indexMap[key.toLowerCase()] = this.v.length - 1; + } + + get(key: string): ExpressionData | undefined { + const index = this.indexMap[key.toLowerCase()]; + if (index === undefined) { + return undefined; + } + + return this.v[index]; + } + + values(): ExpressionData[] { + return this.v; + } + + pairs(): Pair[] { + const result: Pair[] = []; + + for (const key of this.keys) { + result.push({ key, value: this.v[this.indexMap[key.toLowerCase()]] }); + } + + return result; + } +} + +export function isDictionary(x: ExpressionData): x is Dictionary { + return x.kind === Kind.Dictionary; +} diff --git a/actions-expressions/src/data/expressiondata.ts b/actions-expressions/src/data/expressiondata.ts new file mode 100644 index 0000000..e197acd --- /dev/null +++ b/actions-expressions/src/data/expressiondata.ts @@ -0,0 +1,57 @@ +import { Dictionary } from "./dictionary"; +import { Null } from "./null"; +import { Array } from "./array"; +import { StringData } from "./string"; +import { NumberData } from "./number"; +import { BooleanData } from "./boolean"; + +export enum Kind { + String = 0, + Array, + Dictionary, + Boolean, + Number, + CaseSensitiveDictionary, + Null, +} + +export function kindStr(k: Kind): string { + switch (k) { + case Kind.Array: + return "Array"; + case Kind.Boolean: + return "Boolean"; + case Kind.Null: + return "Null"; + case Kind.Number: + return "Number"; + case Kind.Dictionary: + return "Object"; + case Kind.String: + return "String"; + } + + return "unknown"; +} + +export interface ExpressionDataInterface { + kind: Kind; + primitive: boolean; + + coerceString(): string; + + number(): number; +} + +export type ExpressionData = + | Array + | Dictionary + | StringData + | BooleanData + | NumberData + | Null; + +export type Pair = { + key: string; + value: ExpressionData; +}; diff --git a/actions-expressions/src/data/index.ts b/actions-expressions/src/data/index.ts new file mode 100644 index 0000000..9d81eca --- /dev/null +++ b/actions-expressions/src/data/index.ts @@ -0,0 +1,9 @@ +export { Array } from "./array"; +export { BooleanData } from "./boolean"; +export { Dictionary } from "./dictionary"; +export { ExpressionData, Kind } from "./expressiondata"; +export { Null } from "./null"; +export { NumberData } from "./number"; +export { replacer } from "./replacer"; +export { reviver } from "./reviver"; +export { StringData } from "./string"; diff --git a/actions-expressions/src/data/null.ts b/actions-expressions/src/data/null.ts new file mode 100644 index 0000000..1430a21 --- /dev/null +++ b/actions-expressions/src/data/null.ts @@ -0,0 +1,21 @@ +import { + ExpressionData, + ExpressionDataInterface, + Kind, +} from "./expressiondata"; + +export class Null implements ExpressionDataInterface { + constructor() {} + + public readonly kind = Kind.Null; + + public primitive = true; + + coerceString(): string { + return ""; + } + + number(): number { + return 0; + } +} diff --git a/actions-expressions/src/data/number.ts b/actions-expressions/src/data/number.ts new file mode 100644 index 0000000..e809d4f --- /dev/null +++ b/actions-expressions/src/data/number.ts @@ -0,0 +1,23 @@ +import { ExpressionDataInterface, Kind } from "./expressiondata"; + +export class NumberData implements ExpressionDataInterface { + constructor(public readonly value: number) {} + + public readonly kind = Kind.Number; + + public primitive = true; + + coerceString(): string { + if (this.value === -0) { + return "0"; + } + + // Workaround to limit the precision to at most 15 digits. Format the number to a string, then parse + // it back to a number to remove trailing zeroes to prevent numbers to be converted to 1.200000000... + return (+this.value.toFixed(15)).toString(); + } + + number(): number { + return this.value; + } +} diff --git a/actions-expressions/src/data/replacer.test.ts b/actions-expressions/src/data/replacer.test.ts new file mode 100644 index 0000000..80cf675 --- /dev/null +++ b/actions-expressions/src/data/replacer.test.ts @@ -0,0 +1,32 @@ +import { Array } from "./array"; +import { Dictionary } from "./dictionary"; +import { Null } from "./null"; +import { NumberData } from "./number"; +import { replacer } from "./replacer"; +import { StringData } from "./string"; + +describe("replacer", () => { + it("null", () => { + expect(JSON.stringify(new Null(), replacer, " ")).toEqual("null"); + }); + + it("array", () => { + expect( + JSON.stringify( + new Array(new StringData("a"), new StringData("b")), + replacer, + " " + ) + ).toEqual('[\n "a",\n "b"\n]'); + }); + + it("dictionary", () => { + expect( + JSON.stringify( + new Dictionary({ key: "a", value: new NumberData(42) }), + replacer, + " " + ) + ).toEqual('{\n "a": 42\n}'); + }); +}); diff --git a/actions-expressions/src/data/replacer.ts b/actions-expressions/src/data/replacer.ts new file mode 100644 index 0000000..d2eb21d --- /dev/null +++ b/actions-expressions/src/data/replacer.ts @@ -0,0 +1,46 @@ +import { Array } from "./array"; +import { BooleanData } from "./boolean"; +import { Dictionary } from "./dictionary"; +import { Null } from "./null"; +import { NumberData } from "./number"; +import { StringData } from "./string"; + +/** + * Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON + * + * See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#replacer + */ +export function replacer(key: string, value: any): any { + if (value instanceof Null) { + return null; + } + + if (value instanceof BooleanData) { + return value.value; + } + + if (value instanceof NumberData) { + return value.number(); + } + + if (value instanceof StringData) { + return value.coerceString(); + } + + if (value instanceof Array) { + return value.values(); + } + + if (value instanceof Dictionary) { + const pairs = value.pairs(); + + const r: any = {}; + for (const p of pairs) { + r[p.key] = p.value; + } + + return r; + } + + return value; +} diff --git a/actions-expressions/src/data/reviver.test.ts b/actions-expressions/src/data/reviver.test.ts new file mode 100644 index 0000000..93bd647 --- /dev/null +++ b/actions-expressions/src/data/reviver.test.ts @@ -0,0 +1,84 @@ +import { Array } from "./array"; +import { BooleanData } from "./boolean"; +import { Dictionary } from "./dictionary"; +import { ExpressionData } from "./expressiondata"; +import { Null } from "./null"; +import { NumberData } from "./number"; +import { reviver } from "./reviver"; +import { StringData } from "./string"; + +describe("reviver", () => { + const tests: { + name: string; + data: string; + want: ExpressionData; + }[] = [ + { + name: "null", + data: "null", + want: new Null(), + }, + { + name: "number", + data: "1", + want: new NumberData(1), + }, + { + name: "string", + data: `"a"`, + want: new StringData("a"), + }, + { + name: "true boolean", + data: "true", + want: new BooleanData(true), + }, + { + name: "false boolean", + data: "false", + want: new BooleanData(false), + }, + { + name: "array", + data: `[1,2,3]`, + want: new Array(new NumberData(1), new NumberData(2), new NumberData(3)), + }, + { + name: "nested array", + data: `[1,2,[3,4],5]`, + want: new Array( + new NumberData(1), + new NumberData(2), + new Array(new NumberData(3), new NumberData(4)), + new NumberData(5) + ), + }, + { + name: "complex array", + data: `[{"a":[true,2]},{"b":"three"},{"c":null}]`, + want: new Array( + new Dictionary({ + key: "a", + value: new Array(new BooleanData(true), new NumberData(2)), + }), + new Dictionary({ key: "b", value: new StringData("three") }), + new Dictionary({ key: "c", value: new Null() }) + ), + }, + { + name: "dictionary", + data: `{ "object1": {} }`, + want: new Dictionary({ + key: "object1", + value: new Dictionary(), + }), + }, + ]; + + test.each(tests)( + "$name", + ({ data, want }: { data: string; want: ExpressionData }) => { + expect(JSON.parse(data, reviver)).toEqual(want); + } + ); +}); diff --git a/actions-expressions/src/data/reviver.ts b/actions-expressions/src/data/reviver.ts new file mode 100644 index 0000000..d0e00b1 --- /dev/null +++ b/actions-expressions/src/data/reviver.ts @@ -0,0 +1,49 @@ +import { Array as dArray } from "./array"; +import { BooleanData } from "./boolean"; +import { Dictionary } from "./dictionary"; +import { ExpressionData, Pair } from "./expressiondata"; +import { Null } from "./null"; +import { NumberData } from "./number"; +import { StringData } from "./string"; + +/** + * Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object. + * + * See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#reviver + */ +export function reviver(key: string, val: any): ExpressionData { + if (val === null) { + return new Null(); + } + + if (typeof val === "string") { + return new StringData(val); + } + + if (typeof val === "number") { + return new NumberData(val); + } + + if (typeof val === "boolean") { + return new BooleanData(val as boolean); + } + + if (Array.isArray(val)) { + return new dArray(...val); + } + + if (typeof val === "object") { + return new Dictionary( + ...Object.keys(val).map( + (k) => + ({ + key: k, + value: val[k], + } as Pair) + ) + ); + } + + // Pass through value + return val; +} diff --git a/actions-expressions/src/data/string.ts b/actions-expressions/src/data/string.ts new file mode 100644 index 0000000..2c4b457 --- /dev/null +++ b/actions-expressions/src/data/string.ts @@ -0,0 +1,17 @@ +import { ExpressionDataInterface, Kind } from "./expressiondata"; + +export class StringData implements ExpressionDataInterface { + constructor(public readonly value: string) {} + + public readonly kind = Kind.String; + + public primitive = true; + + coerceString(): string { + return this.value; + } + + number(): number { + return Number(this.value); + } +} diff --git a/actions-expressions/src/errors.ts b/actions-expressions/src/errors.ts new file mode 100644 index 0000000..f3519ec --- /dev/null +++ b/actions-expressions/src/errors.ts @@ -0,0 +1,52 @@ +import { Pos, Token, tokenString } from "./lexer"; + +export const MAX_PARSER_DEPTH = 50; +export const MAX_EXPRESSION_LENGTH = 21000; + +export enum ErrorType { + ErrorUnexpectedSymbol, + ErrorUnrecognizedNamedValue, + ErrorUnexpectedEndOfExpression, + + ErrorExceededMaxDepth, + ErrorExceededMaxLength, + ErrorTooFewParameters, + ErrorTooManyParameters, + ErrorUnrecognizedContext, + ErrorUnrecognizedFunction, +} + +export class ExpressionError extends Error { + constructor(private typ: ErrorType, private tok: Token) { + super(`${errorDescription(typ)}: '${tokenString(tok)}'`); + + this.pos = this.tok.pos; + } + + public pos: Pos; +} + +function errorDescription(typ: ErrorType): string { + switch (typ) { + case ErrorType.ErrorUnexpectedEndOfExpression: + return "Unexpected end of expression"; + case ErrorType.ErrorUnexpectedSymbol: + return "Unexpected symbol"; + case ErrorType.ErrorUnrecognizedNamedValue: + return "Unrecognized named-value"; + case ErrorType.ErrorExceededMaxDepth: + return `Exceeded max expression depth ${MAX_PARSER_DEPTH}`; + case ErrorType.ErrorExceededMaxLength: + return `Exceeded max expression length ${MAX_EXPRESSION_LENGTH}`; + case ErrorType.ErrorTooFewParameters: + return "Too few parameters supplied"; + case ErrorType.ErrorTooManyParameters: + return "Too many parameters supplied"; + case ErrorType.ErrorUnrecognizedContext: + return "Unrecognized named-value"; + case ErrorType.ErrorUnrecognizedFunction: + return "Unrecognized function"; + default: // Should never reach here. + return "Unknown error"; + } +} diff --git a/actions-expressions/src/evaluator.test.ts b/actions-expressions/src/evaluator.test.ts new file mode 100644 index 0000000..c8d7e8d --- /dev/null +++ b/actions-expressions/src/evaluator.test.ts @@ -0,0 +1,27 @@ +import * as data from "./data"; +import { Evaluator } from "./evaluator"; +import { Lexer } from "./lexer"; +import { Parser } from "./parser"; + +test("evaluator", () => { + const input = "foo['']"; + + const lexer = new Lexer(input); + const result = lexer.lex(); + + // Parse + const parser = new Parser(result.tokens, ["foo"], []); + const expr = parser.parse(); + + // Evaluate expression + const evaluator = new Evaluator( + expr, + new data.Dictionary({ + key: "foo", + value: new data.Dictionary({ key: "bar", value: new data.NumberData(42) }), + }) + ); + const eresult = evaluator.evaluate(); + + expect(eresult.kind).toBe(data.Kind.Null); +}); diff --git a/actions-expressions/src/evaluator.ts b/actions-expressions/src/evaluator.ts new file mode 100644 index 0000000..a20d36c --- /dev/null +++ b/actions-expressions/src/evaluator.ts @@ -0,0 +1,245 @@ +import { + Binary, + ContextAccess, + Expr, + ExprVisitor, + FunctionCall, + Grouping, + IndexAccess, + Literal, + Logical, + Star, + Unary, +} from "./ast"; +import * as data from "./data"; +import { FilteredArray } from "./filtered_array"; +import { wellKnownFunctions } from "./funcs"; +import { idxHelper } from "./idxHelper"; +import { TokenType } from "./lexer"; +import { equals, falsy, greaterThan, lessThan, truthy } from "./result"; + +export class EvaluationError extends Error {} + +export class Evaluator implements ExprVisitor { + constructor(private n: Expr, private context: data.Dictionary) {} + + public evaluate(): data.ExpressionData { + return this.eval(this.n); + } + + private eval(n: Expr): data.ExpressionData { + return n.accept(this); + } + + visitLiteral(literal: Literal): data.ExpressionData { + return literal.literal; + } + + visitUnary(unary: Unary): data.ExpressionData { + const r = this.eval(unary.expr); + + if (unary.operator.type === TokenType.BANG) { + return new data.BooleanData(falsy(r)); + } + + throw new Error(`unknown unary operator: ${unary.operator.lexeme}`); + } + + visitBinary(binary: Binary): data.ExpressionData { + const left = this.eval(binary.left); + const right = this.eval(binary.right); + + switch (binary.operator.type) { + case TokenType.EQUAL_EQUAL: + return new data.BooleanData(equals(left, right)); + + case TokenType.BANG_EQUAL: + return new data.BooleanData(!equals(left, right)); + + case TokenType.GREATER: + return new data.BooleanData(greaterThan(left, right)); + + case TokenType.GREATER_EQUAL: + return new data.BooleanData( + equals(left, right) || greaterThan(left, right) + ); + + case TokenType.LESS: + return new data.BooleanData(lessThan(left, right)); + + case TokenType.LESS_EQUAL: + return new data.BooleanData(equals(left, right) || lessThan(left, right)); + } + + throw new Error(`unknown binary operator: ${binary.operator.lexeme}`); + } + + visitLogical(logical: Logical): data.ExpressionData { + let result: data.ExpressionData; + + for (const arg of logical.args) { + result = this.eval(arg); + + // Break? + if ( + (logical.operator.type === TokenType.AND && falsy(result)) || + (logical.operator.type === TokenType.OR && truthy(result)) + ) { + break; + } + } + + // result is always assigned before we return here + return result!; + } + + visitGrouping(grouping: Grouping): data.ExpressionData { + return this.eval(grouping.group); + } + + visitContextAccess(contextAccess: ContextAccess): data.ExpressionData { + const r = this.context.get(contextAccess.name.lexeme)!; + return r; + } + + visitIndexAccess(ia: IndexAccess): data.ExpressionData { + let idx: idxHelper; + if (ia.index instanceof Star) { + idx = new idxHelper(true, undefined); + } else { + let idxResult: data.ExpressionData; + try { + idxResult = this.eval(ia.index); + } catch (e) { + throw new Error(`could not evaluate index for index access: ${e}`); + } + idx = new idxHelper(false, idxResult); + } + + const objResult = this.eval(ia.expr); + + let result: data.ExpressionData; + switch (objResult.kind) { + case data.Kind.Array: { + const tobjResult = objResult as data.Array; + if (tobjResult instanceof FilteredArray) { + result = filteredArrayAccess(tobjResult as FilteredArray, idx); + } else { + result = arrayAccess(tobjResult, idx); + } + + break; + } + + case data.Kind.Dictionary: { + const tobjResult = objResult as data.Dictionary; + result = objectAccess(tobjResult, idx); + break; + } + + default: + if (idx.star) { + result = new FilteredArray(); + } else { + result = new data.Null(); + } + } + + return result; + } + + visitFunctionCall(functionCall: FunctionCall): data.ExpressionData { + // Evaluate arguments + const args = functionCall.args.map((arg) => this.eval(arg)); + + return fcall(functionCall, args); + } +} + +function fcall( + fc: FunctionCall, + args: data.ExpressionData[] +): data.ExpressionData { + const f = wellKnownFunctions[fc.functionName.lexeme.toLowerCase()]; + + return f.call(...args); +} + +function filteredArrayAccess( + fa: FilteredArray, + idx: idxHelper +): data.ExpressionData { + const result = new FilteredArray(); + + for (const item of fa.values()) { + // Check the type of the nested item + switch (item.kind) { + case data.Kind.Dictionary: { + const ti = item as data.Dictionary; + if (idx.star) { + for (const v of ti.values()) { + result.add(v); + } + } else if (idx.str !== undefined) { + const v = ti.get(idx.str); + if (v !== undefined) { + result.add(v); + } + } + + break; + } + + case data.Kind.Array: { + const ti = item as data.Array; + if (idx.star) { + for (const v of ti.values()) { + result.add(v); + } + } else if (idx.int !== undefined && idx.int < ti.values().length) { + result.add(ti.get(idx.int)); + } + + break; + } + } + } + + return result; +} + +function arrayAccess(a: data.Array, idx: idxHelper): data.ExpressionData { + if (idx.star) { + const fa = new FilteredArray(); + for (const item of a.values()) { + fa.add(item); + } + + return fa; + } + + if (idx.int !== undefined && idx.int < a.values().length) { + return a.get(idx.int); + } + + return new data.Null(); +} + +function objectAccess( + obj: data.Dictionary, + idx: idxHelper +): data.ExpressionData { + if (idx.star) { + const fa = new FilteredArray(...obj.values()); + return fa; + } + + if (idx.str !== undefined) { + const r = obj.get(idx.str); + if (r !== undefined) { + return r; + } + } + + return new data.Null(); +} diff --git a/actions-expressions/src/filtered_array.ts b/actions-expressions/src/filtered_array.ts new file mode 100644 index 0000000..1d03376 --- /dev/null +++ b/actions-expressions/src/filtered_array.ts @@ -0,0 +1,3 @@ +import * as data from "./data"; + +export class FilteredArray extends data.Array {} diff --git a/actions-expressions/src/funcs.ts b/actions-expressions/src/funcs.ts new file mode 100644 index 0000000..d25d7fa --- /dev/null +++ b/actions-expressions/src/funcs.ts @@ -0,0 +1,63 @@ +import { ErrorType, ExpressionError } from "./errors"; +import { contains } from "./funcs/contains"; +import { endswith } from "./funcs/endswith"; +import { format } from "./funcs/format"; +import { fromjson } from "./funcs/fromjson"; +import { FunctionDefinition, FunctionInfo } from "./funcs/info"; +import { join } from "./funcs/join"; +import { startswith } from "./funcs/startswith"; +import { tojson } from "./funcs/tojson"; +import { Token } from "./lexer"; + +export type ParseContext = { + allowUnknownKeywords: boolean; + extensionContexts: Map; + extensionFunctions: Map; +}; + +export const wellKnownFunctions: { [name: string]: FunctionDefinition } = { + contains: contains, + endswith: endswith, + format: format, + fromjson: fromjson, + join: join, + startswith: startswith, + tojson: tojson, +}; + +// validateFunction returns the function definition for the given function name. +// If the function does not exist or an incorrect number of arguments is provided, +// an error is returned. +export function validateFunction( + context: ParseContext, + identifier: Token, + argCount: number +) { + // Expression function names are case-insensitive. + const name = identifier.lexeme.toLowerCase(); + + let f: FunctionInfo | undefined; + f = wellKnownFunctions[name]; + if (!f) { + f = context.extensionFunctions.get(name); + if (!f) { + if (!context.allowUnknownKeywords) { + throw new ExpressionError( + ErrorType.ErrorUnrecognizedFunction, + identifier + ); + } + + // Skip argument validation for unknown functions + return; + } + } + + if (argCount < f.minArgs) { + throw new ExpressionError(ErrorType.ErrorTooFewParameters, identifier); + } + + if (argCount > f.maxArgs) { + throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier); + } +} diff --git a/actions-expressions/src/funcs/contains.ts b/actions-expressions/src/funcs/contains.ts new file mode 100644 index 0000000..377e447 --- /dev/null +++ b/actions-expressions/src/funcs/contains.ts @@ -0,0 +1,36 @@ +import { Array, BooleanData, ExpressionData, Kind } from "../data"; +import { equals } from "../result"; +import { FunctionDefinition } from "./info"; + +export const contains: FunctionDefinition = { + name: "contains", + description: + "`contains( search, item )`\n\nReturns `true` if `search` contains `item`. If `search` is an array, this function returns `true` if the `item` is an element in the array. If `search` is a string, this function returns `true` if the `item` is a substring of `search`. This function is not case sensitive. Casts values to a string.", + minArgs: 2, + maxArgs: 2, + call: (...args: ExpressionData[]): ExpressionData => { + const left = args[0]; + const right = args[1]; + + if (left.primitive) { + const ls = left.coerceString(); + if (right.primitive) { + const rs = right.coerceString(); + return new BooleanData(ls.toLowerCase().includes(rs.toLowerCase())); + } + } else if (left.kind === Kind.Array) { + const la = left as Array; + if (la.values().length === 0) { + return new BooleanData(false); + } + + for (const v of la.values()) { + if (equals(right, v)) { + return new BooleanData(true); + } + } + } + + return new BooleanData(false); + }, +}; diff --git a/actions-expressions/src/funcs/endswith.ts b/actions-expressions/src/funcs/endswith.ts new file mode 100644 index 0000000..23b8b98 --- /dev/null +++ b/actions-expressions/src/funcs/endswith.ts @@ -0,0 +1,27 @@ +import { BooleanData, ExpressionData } from "../data"; +import { toUpperSpecial } from "../result"; +import { FunctionDefinition } from "./info"; + +export const endswith: FunctionDefinition = { + name: "endsWith", + description: + "`endsWith( searchString, searchValue )`\n\nReturns `true` if `searchString` ends with `searchValue`. This function is not case sensitive. Casts values to a string.", + minArgs: 2, + maxArgs: 2, + call: (...args: ExpressionData[]): ExpressionData => { + const left = args[0]; + if (!left.primitive) { + return new BooleanData(false); + } + + const right = args[1]; + if (!right.primitive) { + return new BooleanData(false); + } + + const ls = toUpperSpecial(left.coerceString()); + const rs = toUpperSpecial(right.coerceString()); + + return new BooleanData(ls.endsWith(rs)); + }, +}; diff --git a/actions-expressions/src/funcs/format.test.ts b/actions-expressions/src/funcs/format.test.ts new file mode 100644 index 0000000..0dc3f04 --- /dev/null +++ b/actions-expressions/src/funcs/format.test.ts @@ -0,0 +1,13 @@ +import { Null, NumberData, StringData } from "../data"; +import { format } from "./format"; + +describe("format", () => { + it("null", () => { + expect(format.call(new StringData("{0}"), new Null())).toEqual(new StringData("")); + }); + it("number", () => { + expect(format.call(new StringData("{0}"), new NumberData(42))).toEqual( + new StringData("42") + ); + }); +}); diff --git a/actions-expressions/src/funcs/format.ts b/actions-expressions/src/funcs/format.ts new file mode 100644 index 0000000..a387e64 --- /dev/null +++ b/actions-expressions/src/funcs/format.ts @@ -0,0 +1,122 @@ +import { ExpressionData, StringData } from "../data"; +import { FunctionDefinition } from "./info"; + +export const format: FunctionDefinition = { + name: "format", + description: + "`format( string, replaceValue0, replaceValue1, ..., replaceValueN)`\n\nReplaces values in the `string`, with the variable `replaceValueN`. Variables in the `string` are specified using the `{N}` syntax, where `N` is an integer. You must specify at least one `replaceValue` and `string`. There is no maximum for the number of variables (`replaceValueN`) you can use. Escape curly braces using double braces.", + minArgs: 1, + maxArgs: 255 /*MAX_ARGUMENTS*/, + call: (...args: ExpressionData[]): ExpressionData => { + const fs = args[0].coerceString(); + + const result: string[] = []; + let index = 0; + + while (index < fs.length) { + const lbrace = fs.indexOf("{", index); + let rbrace = fs.indexOf("}", index); + + // Left brace + if (lbrace >= 0 && (rbrace < 0 || rbrace > lbrace)) { + // Escaped left brace + if (safeCharAt(fs, lbrace + 1) === "{") { + result.push(fs.substr(index, lbrace - index + 1)); + index = lbrace + 2; + continue; + } + + // Left brace, number, optional format specifiers, right brace + if (rbrace > lbrace + 1) { + const argIndex = readArgIndex(fs, lbrace + 1); + if (argIndex.success) { + // Check parameter count + if (1 + argIndex.result > args.length - 1) { + throw new Error( + `The following format string references more arguments than were supplied: ${fs}` + ); + } + + // Append the portion before the left brace + if (lbrace > index) { + result.push(fs.substr(index, lbrace - index)); + } + + // Append the arg + result.push(`${args[1 + argIndex.result].coerceString()}`); + index = rbrace + 1; + continue; + } + } + + throw new Error(`The following format string is invalid: ${fs}`); + } + // Right brace + else if (rbrace >= 0) { + // Escaped right brace + if (safeCharAt(fs, rbrace + 1) === "}") { + result.push(fs.substr(index, rbrace - index + 1)); + index = rbrace + 2; + } else { + throw new Error(`The following format string is invalid: ${fs}`); + } + } + // Last segment + else { + result.push(fs.substr(index)); + break; + } + } + + return new StringData(result.join("")); + }, +}; + +function safeCharAt(string: string, index: number): string { + if (string.length > index) { + return string[index]; + } + + return "\0"; +} + +function readArgIndex(string: string, startIndex: number): ArgIndex { + // Count the number of digits + let length = 0; + while (true) { + const nextChar = safeCharAt(string, startIndex + length); + if (nextChar >= "0" && nextChar <= "9") { + length++; + } else { + break; + } + } + + // Validate at least one digit + if (length < 1) { + return { + success: false, + }; + } + + // Parse the number + const endIndex = startIndex + length - 1; + const result = parseInt(string.substr(startIndex, length)); + return { + success: !isNaN(result), + result: result, + endIndex: endIndex, + }; +} + +interface ArgIndex { + success: boolean; + result: number; + endIndex: number; +} + +interface FormatSpecifiers { + success: boolean; + result: string; + rbrace: number; +} diff --git a/actions-expressions/src/funcs/fromjson.ts b/actions-expressions/src/funcs/fromjson.ts new file mode 100644 index 0000000..2831f69 --- /dev/null +++ b/actions-expressions/src/funcs/fromjson.ts @@ -0,0 +1,22 @@ +import { ExpressionData } from "../data"; +import { reviver } from "../data/reviver"; +import { FunctionDefinition } from "./info"; + +export const fromjson: FunctionDefinition = { + name: "fromJson", + description: + "`fromJSON(value)`\n\nReturns a JSON object or JSON data type for `value`. You can use this function to provide a JSON object as an evaluated expression or to convert environment variables from a string.", + minArgs: 1, + maxArgs: 1, + call: (...args: ExpressionData[]): ExpressionData => { + const input = args[0]; + const is = input.coerceString(); + + if (is.trim() === "") { + throw new Error("empty input"); + } + + const d = JSON.parse(is, reviver); + return d; + }, +}; diff --git a/actions-expressions/src/funcs/info.ts b/actions-expressions/src/funcs/info.ts new file mode 100644 index 0000000..949ab2b --- /dev/null +++ b/actions-expressions/src/funcs/info.ts @@ -0,0 +1,14 @@ +import { ExpressionData } from "../data"; + +export interface FunctionInfo { + name: string; + + description?: string; + + minArgs: number; + maxArgs: number; +} + +export interface FunctionDefinition extends FunctionInfo { + call: (...args: ExpressionData[]) => ExpressionData; +} diff --git a/actions-expressions/src/funcs/join.ts b/actions-expressions/src/funcs/join.ts new file mode 100644 index 0000000..7369e25 --- /dev/null +++ b/actions-expressions/src/funcs/join.ts @@ -0,0 +1,35 @@ +import { Array, ExpressionData, Kind, StringData } from "../data"; +import { FunctionDefinition } from "./info"; + +export const join: FunctionDefinition = { + name: "join", + description: + "`join( array, optionalSeparator )`\n\nThe value for `array` can be an array or a string. All values in `array` are concatenated into a string. If you provide `optionalSeparator`, it is inserted between the concatenated values. Otherwise, the default separator `,` is used. Casts values to a string.", + minArgs: 1, + maxArgs: 2, + call: (...args: ExpressionData[]): ExpressionData => { + // Primitive + if (args[0].primitive) { + return new StringData(args[0].coerceString()); + } + + // Array + if (args[0].kind === Kind.Array) { + // Separator + let separator = ","; + if (args.length > 1 && args[1].primitive) { + separator = args[1].coerceString(); + } + + // Convert items to strings + return new StringData( + (args[0] as Array) + .values() + .map((item) => item.coerceString()) + .join(separator) + ); + } + + return new StringData(""); + }, +}; diff --git a/actions-expressions/src/funcs/startswith.ts b/actions-expressions/src/funcs/startswith.ts new file mode 100644 index 0000000..d59058d --- /dev/null +++ b/actions-expressions/src/funcs/startswith.ts @@ -0,0 +1,27 @@ +import { BooleanData, ExpressionData } from "../data"; +import { toUpperSpecial } from "../result"; +import { FunctionDefinition } from "./info"; + +export const startswith: FunctionDefinition = { + name: "startsWith", + description: + "`startsWith( searchString, searchValue )`\n\nReturns `true` when `searchString` starts with `searchValue`. This function is not case sensitive. Casts values to a string.", + minArgs: 2, + maxArgs: 2, + call: (...args: ExpressionData[]): ExpressionData => { + const left = args[0]; + if (!left.primitive) { + return new BooleanData(false); + } + + const right = args[1]; + if (!right.primitive) { + return new BooleanData(false); + } + + const ls = toUpperSpecial(left.coerceString()); + const rs = toUpperSpecial(right.coerceString()); + + return new BooleanData(ls.startsWith(rs)); + }, +}; diff --git a/actions-expressions/src/funcs/tojson.ts b/actions-expressions/src/funcs/tojson.ts new file mode 100644 index 0000000..75466e7 --- /dev/null +++ b/actions-expressions/src/funcs/tojson.ts @@ -0,0 +1,14 @@ +import { ExpressionData, StringData } from "../data"; +import { replacer } from "../data/replacer"; +import { FunctionDefinition } from "./info"; + +export const tojson: FunctionDefinition = { + name: "toJson", + description: + "`toJSON(value)`\n\nReturns a pretty-print JSON representation of `value`. You can use this function to debug the information provided in contexts.", + minArgs: 1, + maxArgs: 1, + call: (...args: ExpressionData[]): ExpressionData => { + return new StringData(JSON.stringify(args[0], replacer, " ")); + }, +}; diff --git a/actions-expressions/src/idxHelper.ts b/actions-expressions/src/idxHelper.ts new file mode 100644 index 0000000..2ced087 --- /dev/null +++ b/actions-expressions/src/idxHelper.ts @@ -0,0 +1,20 @@ +import { ExpressionData } from "./data"; + +export class idxHelper { + public readonly str: string | undefined; + public readonly int: number | undefined; + + constructor(public readonly star: boolean, idx: ExpressionData | undefined) { + if (!star) { + if (idx!.primitive) { + this.str = idx!.coerceString(); + } + + let f = idx!.number(); + if (!isNaN(f) && isFinite(f) && f >= 0) { + f = Math.floor(f); + this.int = f; + } + } + } +} diff --git a/actions-expressions/src/index.ts b/actions-expressions/src/index.ts new file mode 100644 index 0000000..0d1e265 --- /dev/null +++ b/actions-expressions/src/index.ts @@ -0,0 +1,6 @@ +export { complete } from "./completion"; +export * as data from "./data"; +export { ExpressionError } from "./errors"; +export { Evaluator } from "./evaluator"; +export { Lexer } from "./lexer"; +export { Parser } from "./parser"; diff --git a/actions-expressions/src/lexer.test.ts b/actions-expressions/src/lexer.test.ts new file mode 100644 index 0000000..4169e0f --- /dev/null +++ b/actions-expressions/src/lexer.test.ts @@ -0,0 +1,144 @@ +import { Lexer, Token, TokenType } from "./lexer"; + +describe("lexer", () => { + const tests: { + input: string; + tokenType: TokenType[]; + token?: Token; + }[] = [ + { input: "<", tokenType: [TokenType.LESS] }, + { input: ">", tokenType: [TokenType.GREATER] }, + + { input: "!=", tokenType: [TokenType.BANG_EQUAL] }, + { input: "==", tokenType: [TokenType.EQUAL_EQUAL] }, + { input: "<=", tokenType: [TokenType.LESS_EQUAL] }, + { input: ">=", tokenType: [TokenType.GREATER_EQUAL] }, + + { input: "&&", tokenType: [TokenType.AND] }, + { input: "||", tokenType: [TokenType.OR] }, + + // Numbers + { input: "12", tokenType: [TokenType.NUMBER] }, + { input: "12.0", tokenType: [TokenType.NUMBER] }, + { input: "0", tokenType: [TokenType.NUMBER] }, + { input: "-0", tokenType: [TokenType.NUMBER] }, + { input: "-12.0", tokenType: [TokenType.NUMBER] }, + + // Strings + { input: "'It''s okay'", tokenType: [TokenType.STRING] }, + { + input: "format('{0} == ''queued''', needs)", + tokenType: [ + TokenType.IDENTIFIER, + TokenType.LEFT_PAREN, + TokenType.STRING, + TokenType.COMMA, + TokenType.IDENTIFIER, + TokenType.RIGHT_PAREN, + ], + }, + + // Arrays + { + input: "[1,2]", + tokenType: [ + TokenType.LEFT_BRACKET, + TokenType.NUMBER, + TokenType.COMMA, + TokenType.NUMBER, + TokenType.RIGHT_BRACKET, + ], + }, + + // Simple expressions + { + input: "1 == 2", + tokenType: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER], + }, + { + input: "1== 1", + tokenType: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER], + }, + { + input: "1< 1", + tokenType: [TokenType.NUMBER, TokenType.LESS, TokenType.NUMBER], + }, + + // Identifiers + { + input: "github", + tokenType: [TokenType.IDENTIFIER], + token: { + type: TokenType.IDENTIFIER, + lexeme: "github", + pos: { + line: 0, + column: 0, + }, + }, + }, + + // Keywords + { + input: "true", + tokenType: [TokenType.TRUE], + token: { + type: TokenType.TRUE, + lexeme: "true", + pos: { + line: 0, + column: 0, + }, + }, + }, + + { + input: "false", + tokenType: [TokenType.FALSE], + token: { + type: TokenType.FALSE, + lexeme: "false", + pos: { + line: 0, + column: 0, + }, + }, + }, + + { + input: "null", + tokenType: [TokenType.NULL], + token: { + type: TokenType.NULL, + lexeme: "null", + pos: { + line: 0, + column: 0, + }, + }, + }, + ]; + + test.each(tests)( + "$input", + ({ + input, + tokenType, + token, + }: { + input: string; + tokenType: TokenType[]; + token?: Token; + }) => { + const l = new Lexer(input); + + const r = l.lex(); + + const want = r.tokens.map((t) => t.type); + + tokenType.push(TokenType.EOF); + + expect(want).toEqual(tokenType); + } + ); +}); diff --git a/actions-expressions/src/lexer.ts b/actions-expressions/src/lexer.ts new file mode 100644 index 0000000..f838367 --- /dev/null +++ b/actions-expressions/src/lexer.ts @@ -0,0 +1,425 @@ +import { StringData } from "./data"; +import { MAX_EXPRESSION_LENGTH } from "./errors"; + +export enum TokenType { + UNKNOWN, + LEFT_PAREN, + RIGHT_PAREN, + LEFT_BRACKET, + RIGHT_BRACKET, + COMMA, + DOT, + + BANG, + BANG_EQUAL, + EQUAL_EQUAL, + GREATER, + GREATER_EQUAL, + LESS, + LESS_EQUAL, + AND, + OR, + + STAR, + NUMBER, + STRING, + IDENTIFIER, + TRUE, + FALSE, + NULL, + + EOF, +} + +export type Pos = { + line: number; + column: number; +}; + +export type Token = { + type: TokenType; + + lexeme: string; + value?: string | number | boolean; + + pos: Pos; +}; + +export function tokenString(tok: Token): string { + switch (tok.type) { + case TokenType.EOF: + return "EOF"; + case TokenType.NUMBER: + return tok.lexeme; + case TokenType.STRING: + return tok.value!.toString(); + default: + return tok.lexeme; + } +} + +export type Result = { + tokens: Token[]; +}; + +export class Lexer { + private start = 0; + private offset = 0; + + private line = 0; + private lastLineOffset = 0; + + private tokens: Token[] = []; + + constructor(private input: string) {} + + lex(): Result { + if (this.input.length > MAX_EXPRESSION_LENGTH) { + throw new Error("ErrorExceededMaxLength"); + } + + while (!this.atEnd()) { + this.start = this.offset; + const c = this.next(); + + switch (c) { + case "(": + this.addToken(TokenType.LEFT_PAREN); + break; + case ")": + this.addToken(TokenType.RIGHT_PAREN); + break; + case "[": + this.addToken(TokenType.LEFT_BRACKET); + break; + case "]": + this.addToken(TokenType.RIGHT_BRACKET); + break; + case ",": + this.addToken(TokenType.COMMA); + break; + case ".": + if ( + this.previous() != TokenType.IDENTIFIER && + this.previous() != TokenType.RIGHT_BRACKET && + this.previous() != TokenType.RIGHT_PAREN && + this.previous() != TokenType.STAR + + ) { + this.consumeNumber(); + } else { + this.addToken(TokenType.DOT); + } + break; + + case "-": + case "+": + this.consumeNumber(); + break; + + case "!": + this.addToken( + this.match("=") ? TokenType.BANG_EQUAL : TokenType.BANG + ); + break; + + case "=": + if (!this.match("=")) { + // Illegal; continue reading until we hit a boundary character and return an error + this.consumeIdentifier(); + break; + } + + this.addToken(TokenType.EQUAL_EQUAL); + break; + + case "<": + this.addToken( + this.match("=") ? TokenType.LESS_EQUAL : TokenType.LESS + ); + break; + + case ">": + this.addToken( + this.match("=") ? TokenType.GREATER_EQUAL : TokenType.GREATER + ); + break; + + case "&": + if (!this.match("&")) { + // Illegal; continue reading until we hit a boundary character and return an error + this.consumeIdentifier(); + break; + } + + this.addToken(TokenType.AND); + break; + case "|": + if (!this.match("|")) { + // Illegal; continue reading until we hit a boundary character and return an error + this.consumeIdentifier(); + break; + } + + this.addToken(TokenType.OR); + break; + + case "*": + this.addToken(TokenType.STAR); + break; + + // Ignore whitespace. + case " ": + case "\r": + case "\t": + break; + + case "\n": + ++this.line; + this.lastLineOffset = this.offset; + break; + + case "'": + this.consumeString(); + break; + + default: + switch (true) { + case isDigit(c): + this.consumeNumber(); + break; + + default: + this.consumeIdentifier(); + break; + } + } + } + + this.tokens.push({ + type: TokenType.EOF, + lexeme: "", + pos: this.pos(), + }); + + return { + tokens: this.tokens, + }; + } + + private pos(): Pos { + return { + line: this.line, + column: this.start - this.lastLineOffset, + }; + } + + private atEnd(): boolean { + return this.offset >= this.input.length; + } + + private peek(): string { + if (this.atEnd()) { + return "\0"; + } + + return this.input[this.offset]; + } + + private peekNext(): string { + if (this.offset + 1 >= this.input.length) { + return "\0"; + } + + return this.input[this.offset + 1]; + } + + private previous(): TokenType { + const l = this.tokens.length; + if (l == 0) { + return TokenType.EOF; + } + + return this.tokens[l - 1].type; + } + + private next(): string { + return this.input[this.offset++]; + } + + private reverse(): string { + return this.input[--this.offset]; + } + + private match(expected: string): boolean { + if (this.atEnd()) { + return false; + } + if (this.input[this.offset] !== expected) { + return false; + } + + this.offset++; + return true; + } + + private addToken(type: TokenType, value?: string | number | boolean) { + this.tokens.push({ + type, + lexeme: this.input.substring(this.start, this.offset), + pos: this.pos(), + value, + }); + } + + private consumeNumber() { + while (!this.atEnd() && (!isBoundary(this.peek()) || this.peek() == ".")) { + this.next(); + } + + const lexeme = this.input.substring(this.start, this.offset); + const value = new StringData(lexeme).number(); + + if (isNaN(value)) { + throw new Error( + `Unexpected symbol: '${lexeme}'. Located at position ${ + this.start + 1 + } within expression: ${this.input}` + ); + } + + this.addToken(TokenType.NUMBER, value); + } + + private consumeString() { + while ((this.peek() !== "'" || this.peekNext() === "'") && !this.atEnd()) { + if (this.peek() === "\n") this.line++; + if (this.peek() === "'" && this.peekNext() === "'") { + // Escaped "'", consume + this.next(); + } + this.next(); + } + + if (this.atEnd()) { + // Unterminated string + throw new Error( + `Unexpected symbol: '${this.input.substring( + this.start + )}'. Located at position ${this.start + 1} within expression: ${ + this.input + }` + ); + } + + // Closing ' + this.next(); + + // Trim the surrounding quotes. + let value = this.input.substring(this.start + 1, this.offset - 1); + value = value.replace("''", "'"); + + this.addToken(TokenType.STRING, value); + } + + private consumeIdentifier() { + while (!this.atEnd() && !isBoundary(this.peek())) { + this.next(); + } + + let tokenType = TokenType.IDENTIFIER; + let tokenValue = undefined; + + const lexeme = this.input.substring(this.start, this.offset); + + if (this.previous() != TokenType.DOT) { + switch (lexeme) { + case "true": + tokenType = TokenType.TRUE; + break; + + case "false": + tokenType = TokenType.FALSE; + break; + + case "null": + tokenType = TokenType.NULL; + break; + + case "NaN": + tokenType = TokenType.NUMBER; + tokenValue = NaN; + break; + + case "Infinity": + tokenType = TokenType.NUMBER; + tokenValue = Infinity; + break; + } + } + + if (!isLegalIdentifier(lexeme)) { + throw new Error( + `Unexpected symbol: '${lexeme}'. Located at position ${ + this.start + 1 + } within expression: ${this.input}` + ); + } + + this.addToken(tokenType, tokenValue); + } +} + +function isDigit(c: string) { + return c >= "0" && c <= "9"; +} + +function isBoundary(c: string): boolean { + switch (c) { + case "(": + case "[": + case ")": + case "]": + case ",": + case ".": + case "!": + case ">": + case "<": + case "=": + case "&": + case "|": + return true; + } + + return /\s/.test(c); +} + +function isLegalIdentifier(str: string): boolean { + if (str == "") { + return false; + } + + const first = str[0]; + if ( + (first >= "a" && first <= "z") || + (first >= "A" && first <= "Z") || + first == "_" + ) { + for (const c of str.substring(1).split("")) { + if ( + (c >= "a" && c <= "z") || + (c >= "A" && c <= "Z") || + (c >= "0" && c <= "9") || + c == "_" || + c == "-" + ) { + // OK + } else { + return false; + } + } + + return true; + } + return false; +} diff --git a/actions-expressions/src/parser.ts b/actions-expressions/src/parser.ts new file mode 100644 index 0000000..8170931 --- /dev/null +++ b/actions-expressions/src/parser.ts @@ -0,0 +1,397 @@ +import { + Binary, + ContextAccess, + Expr, + FunctionCall, + Grouping, + IndexAccess, + Literal, + Logical, + Star, + Unary, +} from "./ast"; +import * as data from "./data"; +import { ErrorType, ExpressionError, MAX_PARSER_DEPTH } from "./errors"; +import { ParseContext, validateFunction } from "./funcs"; +import { FunctionInfo } from "./funcs/info"; +import { Token, TokenType } from "./lexer"; + +export class Parser { + private extContexts: Map; + private extFuncs: Map; + + private offset: number = 0; + private depth: number = 0; + + private context: ParseContext; + + /** + * Constructs a new parser for the given tokens + * + * @param tokens Tokens to build a parse tree from + * @param extensionContexts Available context names + * @param extensionFunctions Available functions (beyond the built-in ones) + */ + constructor( + private tokens: Token[], + extensionContexts: string[], + extensionFunctions: FunctionInfo[] + ) { + this.extContexts = new Map(); + this.extFuncs = new Map(); + + for (const contextName of extensionContexts) { + this.extContexts.set(contextName.toLowerCase(), true); + } + + for (const { name, func } of extensionFunctions.map((x) => ({ + name: x.name, + func: x, + }))) { + this.extFuncs.set(name.toLowerCase(), func); + } + + this.context = { + allowUnknownKeywords: false, + extensionContexts: this.extContexts, + extensionFunctions: this.extFuncs, + }; + } + + public parse(): Expr { + let result!: Expr; + + // No tokens + if (this.atEnd()) { + return result; + } + + result = this.expression(); + + if (!this.atEnd()) { + throw this.buildError(ErrorType.ErrorUnexpectedSymbol, this.peek()); + } + + return result; + } + + private expression(): Expr { + this.incrDepth(); + + try { + return this.logicalOr(); + } finally { + this.decrDepth(); + } + } + + private logicalOr(): Expr { + // && is higher precedence than || + let expr = this.logicalAnd(); + + if (this.check(TokenType.OR)) { + // Track depth + this.incrDepth(); + + try { + const logical = new Logical(this.peek(), [expr]); + expr = logical; + + while (this.match(TokenType.OR)) { + const right = this.logicalAnd(); + logical.args.push(right); + } + } finally { + this.decrDepth(); + } + } + + return expr; + } + + private logicalAnd(): Expr { + // == and != are higher precedence than && + let expr = this.equality(); + + if (this.check(TokenType.AND)) { + // Track depth + this.incrDepth(); + + try { + const logical = new Logical(this.peek(), [expr]); + expr = logical; + + while (this.match(TokenType.AND)) { + const right = this.equality(); + logical.args.push(right); + } + } finally { + this.decrDepth(); + } + } + + return expr; + } + + private equality(): Expr { + // >, >=, <, <= are higher precedence than == and != + let expr = this.comparison(); + + while (this.match(TokenType.BANG_EQUAL, TokenType.EQUAL_EQUAL)) { + const operator = this.previous(); + const right = this.comparison(); + + expr = new Binary(expr, operator, right); + } + + return expr; + } + + private comparison(): Expr { + // ! is higher precedence than >, >=, <, <= + let expr = this.unary(); + + while ( + this.match( + TokenType.GREATER, + TokenType.GREATER_EQUAL, + TokenType.LESS, + TokenType.LESS_EQUAL + ) + ) { + const operator = this.previous(); + const right = this.unary(); + + expr = new Binary(expr, operator, right); + } + + return expr; + } + + private unary(): Expr { + if (this.match(TokenType.BANG)) { + // Track depth + this.incrDepth(); + + const operator = this.previous(); + const unary = this.unary(); + + try { + return new Unary(operator, unary); + } finally { + this.decrDepth(); + } + } + + return this.index(); + } + + private index(): Expr { + let expr = this.call(); + + let depthIncreased = 0; + + if ( + expr instanceof Grouping || + expr instanceof FunctionCall || + expr instanceof ContextAccess + ) { + let cont = true; + while (cont) { + switch (true) { + case this.match(TokenType.LEFT_BRACKET): + let indexExpr: Expr; + if (this.match(TokenType.STAR)) { + indexExpr = new Star(); + } else { + indexExpr = this.expression(); + } + + this.consume( + TokenType.RIGHT_BRACKET, + ErrorType.ErrorUnexpectedSymbol + ); + + // Track depth + this.incrDepth(); + depthIncreased++; + expr = new IndexAccess(expr, indexExpr); + break; + + case this.match(TokenType.DOT): + // Track depth + this.incrDepth(); + depthIncreased++; + + if (this.match(TokenType.IDENTIFIER)) { + let property = this.previous(); + expr = new IndexAccess( + expr, + new Literal(new data.StringData(property.lexeme)) + ); + } else if (this.match(TokenType.STAR)) { + expr = new IndexAccess(expr, new Star()); + } else { + throw this.buildError( + ErrorType.ErrorUnexpectedSymbol, + this.peek() + ); + } + + break; + + default: + cont = false; + } + } + } + + for (let i = 0; i < depthIncreased; i++) { + this.decrDepth(); + } + + return expr; + } + + private call(): Expr { + if (!this.check(TokenType.IDENTIFIER)) { + return this.primary(); + } + + const identifier = this.next(); + + if (!this.match(TokenType.LEFT_PAREN)) { + if (!this.extContexts.has(identifier.lexeme.toLowerCase())) { + throw this.buildError(ErrorType.ErrorUnrecognizedContext, identifier); + } + return new ContextAccess(identifier); + } + + // Function call + const args: Expr[] = []; + + // Arguments + while (!this.match(TokenType.RIGHT_PAREN)) { + const aexp = this.expression(); + + args.push(aexp); + + if (!this.check(TokenType.RIGHT_PAREN)) { + this.consume(TokenType.COMMA, ErrorType.ErrorUnexpectedSymbol); + } + } + + validateFunction(this.context, identifier, args.length); + + return new FunctionCall(identifier, args); + } + + private primary(): Expr { + switch (true) { + case this.match(TokenType.FALSE): + return new Literal(new data.BooleanData(false)); + + case this.match(TokenType.TRUE): + return new Literal(new data.BooleanData(true)); + + case this.match(TokenType.NULL): + return new Literal(new data.Null()); + + case this.match(TokenType.NUMBER): + return new Literal(new data.NumberData(this.previous().value as number)); + + case this.match(TokenType.STRING): + return new Literal(new data.StringData(this.previous().value as string)); + + case this.match(TokenType.LEFT_PAREN): + const expr = this.expression(); + + if (this.atEnd()) { + throw this.buildError( + ErrorType.ErrorUnexpectedEndOfExpression, + this.previous() + ); // Back up to get the last token before the EOF + } + + this.consume(TokenType.RIGHT_PAREN, ErrorType.ErrorUnexpectedSymbol); + return new Grouping(expr); + + case this.atEnd(): + throw this.buildError( + ErrorType.ErrorUnexpectedEndOfExpression, + this.previous() + ); // Back up to get the last token before the EOF + } + + throw this.buildError(ErrorType.ErrorUnexpectedSymbol, this.peek()); + } + + // match consumes the next token if it matches any of the given types + private match(...tokenTypes: TokenType[]): boolean { + for (const tokenType of tokenTypes) { + if (this.check(tokenType)) { + this.next(); + return true; + } + } + + return false; + } + + // check peeks whether the next token is of the given type + private check(tokenType: TokenType): boolean { + if (this.atEnd()) { + return false; + } + + return this.peek().type == tokenType; + } + + // atEnd peeks whether the next token is EOF + private atEnd(): boolean { + return this.peek().type == TokenType.EOF; + } + + private next(): Token { + if (!this.atEnd()) { + this.offset++; + } + + return this.previous(); + } + + private peek(): Token { + return this.tokens[this.offset]; + } + + // previous returns the previous token + private previous(): Token { + return this.tokens[this.offset - 1]; + } + + // consume attempts to consume the next token if it matches the given type. It returns an error of + // the given ParseErrorKind otherwise. + private consume(tokenType: TokenType, errorType: ErrorType) { + if (this.check(tokenType)) { + this.next(); + return; + } + + throw this.buildError(errorType, this.peek()); + } + + private incrDepth() { + this.depth++; + if (this.depth > MAX_PARSER_DEPTH) { + throw this.buildError(ErrorType.ErrorExceededMaxDepth, this.peek()); + } + } + + private decrDepth() { + this.depth--; + } + + private buildError(errType: ErrorType, token: Token): Error { + return new ExpressionError(errType, token); + } +} diff --git a/actions-expressions/src/result.test.ts b/actions-expressions/src/result.test.ts new file mode 100644 index 0000000..2bc1d48 --- /dev/null +++ b/actions-expressions/src/result.test.ts @@ -0,0 +1,101 @@ +import { BooleanData, ExpressionData, NumberData, StringData } from "./data"; +import { coerceTypes, toUpperSpecial } from "./result"; + +describe("coerceTypes", () => { + const tests: { + name: string; + data: { + l: ExpressionData; + r: ExpressionData; + }; + wantL: ExpressionData; + wantR: ExpressionData; + }[] = [ + { + name: "number-bool", + data: { l: new NumberData(1), r: new BooleanData(true) }, + wantL: new NumberData(1), + wantR: new NumberData(1), + }, + { + name: "number-bool-false", + data: { l: new NumberData(1), r: new BooleanData(false) }, + wantL: new NumberData(1), + wantR: new NumberData(0), + }, + { + name: "bool-number-false", + data: { l: new BooleanData(false), r: new NumberData(1) }, + wantL: new NumberData(0), + wantR: new NumberData(1), + }, + { + name: "number-number", + data: { l: new NumberData(1), r: new NumberData(2) }, + wantL: new NumberData(1), + wantR: new NumberData(2), + }, + { + name: "string-string", + data: { l: new StringData("a"), r: new StringData("b") }, + wantL: new StringData("a"), + wantR: new StringData("b"), + }, + { + name: "string-number", + data: { l: new StringData("a"), r: new NumberData(1) }, + wantL: new NumberData(NaN), + wantR: new NumberData(1), + }, + { + name: "number-string", + data: { l: new NumberData(1), r: new StringData("a") }, + wantL: new NumberData(1), + wantR: new NumberData(NaN), + }, + { + name: "bool-bool", + data: { l: new BooleanData(false), r: new BooleanData(true) }, + wantL: new BooleanData(false), + wantR: new BooleanData(true), + }, + ]; + + test.each(tests)( + "$name", + ({ + data, + wantL, + wantR, + }: { + data: { l: ExpressionData; r: ExpressionData }; + wantL: ExpressionData; + wantR: ExpressionData; + }) => { + const [gotL, gotR] = coerceTypes(data.l, data.r); + expect(gotL).toEqual(wantL); + expect(gotR).toEqual(wantR); + } + ); +}); + +describe("toUpperSpecial", () => { + const tests: { input: string; want: string }[] = [ + { input: "", want: "" }, + { input: "abc", want: "ABC" }, + { input: "ıabc", want: "ıABC" }, + { input: "ııabc", want: "ııABC" }, + { input: "abcı", want: "ABCı" }, + { input: "abcıı", want: "ABCıı" }, + { input: "abcıdef", want: "ABCıDEF" }, + { input: "abcııdef", want: "ABCııDEF" }, + { input: "abcıdefıghi", want: "ABCıDEFıGHI" }, + ]; + + test.each(tests)( + "$input", + ({ input, want }: { input: string; want: string }) => { + expect(toUpperSpecial(input)).toEqual(want); + } + ); +}); diff --git a/actions-expressions/src/result.ts b/actions-expressions/src/result.ts new file mode 100644 index 0000000..06b65ef --- /dev/null +++ b/actions-expressions/src/result.ts @@ -0,0 +1,229 @@ +import * as data from "./data"; + +export function falsy(d: data.ExpressionData): boolean { + switch (d.kind) { + case data.Kind.Null: + return true; + + case data.Kind.Boolean: + return !d.value; + + case data.Kind.Number: + const dv = d.value; + return dv === 0 || isNaN(dv); + + case data.Kind.String: + return d.value.length === 0; + } + + return false; +} + +export function truthy(d: data.ExpressionData): boolean { + return !falsy(d); +} + +// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. +// Except objects are not coerced to primitives. +export function coerceTypes( + li: data.ExpressionData, + ri: data.ExpressionData +): [data.ExpressionData, data.ExpressionData] { + let lv = li; + let rv = ri; + + // Do nothing, same kind + if (li.kind === ri.kind) { + return [lv, rv]; + } + + switch (li.kind) { + // Number, String + case data.Kind.Number: + if (ri.kind === data.Kind.String) { + rv = new data.NumberData(ri.number()); + return [lv, rv]; + } + + break; + + // String, Number + case data.Kind.String: + if (ri.kind === data.Kind.Number) { + lv = new data.NumberData(li.number()); + return [lv, rv]; + } + + break; + + // Boolean|Null, Any + case data.Kind.Null: + case data.Kind.Boolean: + lv = new data.NumberData(li.number()); + return coerceTypes(lv, rv); + } + + // Any, Boolean|Null + switch (ri.kind) { + case data.Kind.Null: + case data.Kind.Boolean: + rv = new data.NumberData(ri.number()); + return coerceTypes(lv, rv); + } + + return [lv, rv]; +} + +// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. +// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. +export function equals( + lhs: data.ExpressionData, + rhs: data.ExpressionData +): boolean { + let [lv, rv] = coerceTypes(lhs, rhs); + + if (lv.kind != rv.kind) { + return false; + } + + switch (lv.kind) { + // Null, Null + case data.Kind.Null: + return true; + + // Number, Number + case data.Kind.Number: + const ld = lv.value; + const rd = (rv as data.NumberData).value; + if (isNaN(ld) || isNaN(rd)) { + return false; + } + + return ld == rd; + + // String, String + case data.Kind.String: + const ls = lv.value; + const rs = (rv as data.StringData).value; + return toUpperSpecial(ls) === toUpperSpecial(rs); + + // Boolean, Boolean + case data.Kind.Boolean: + const lb = lv.value; + const rb = (rv as data.BooleanData).value; + return lb == rb; + + // Object, Object + case data.Kind.Dictionary: + case data.Kind.Array: + // Check reference equality + return lv === rv; + } + + return false; +} + +// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. +// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. +export function greaterThan( + lhs: data.ExpressionData, + rhs: data.ExpressionData +): boolean { + const [lv, rv] = coerceTypes(lhs, rhs); + + if (lv.kind != rv.kind) { + return false; + } + + switch (lv.kind) { + // Number, Number + case data.Kind.Number: + const lf = lv.value; + const rf = (rv as data.NumberData).value; + if (isNaN(lf) || isNaN(rf)) { + return false; + } + + return lf > rf; + + // String, String + case data.Kind.String: + let ls = lv.value; + let rs = (rv as data.StringData).value; + ls = toUpperSpecial(ls); + rs = toUpperSpecial(rs); + return ls > rs; + + // Boolean, Boolean + case data.Kind.Boolean: + const lb = (lv as data.BooleanData).value; + const rb = (rv as data.BooleanData).value; + return lb && !rb; + } + + return false; +} + +// Similar to the Javascript abstract equality comparison algorithm http://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3. +// Except string comparison is OrdinalIgnoreCase, and objects are not coerced to primitives. +export function lessThan( + lhs: data.ExpressionData, + rhs: data.ExpressionData +): boolean { + const [lv, rv] = coerceTypes(lhs, rhs); + + if (lv.kind != rv.kind) { + return false; + } + + switch (lv.kind) { + // Number, Number + case data.Kind.Number: + const lf = lv.value; + const rf = (rv as data.NumberData).value; + if (isNaN(lf) || isNaN(rf)) { + return false; + } + + return lf < rf; + + // String, String + case data.Kind.String: + let ls = lv.value; + let rs = (rv as data.StringData).value; + ls = toUpperSpecial(ls); + rs = toUpperSpecial(rs); + return ls < rs; + + // Boolean, Boolean + case data.Kind.Boolean: + const lb = lv.value; + const rb = (rv as data.BooleanData).value; + return !lb && rb; + } + + return false; +} + +// Do not toUpper the small-dotless-ı +export function toUpperSpecial(s: string): string { + const sb: string[] = []; + let i = 0; + let len = s.length; + let found = s.indexOf("ı"); + while (i < len) { + if (i < found) { + sb.push(s.substring(i, found).toUpperCase()); // Append upper segment + i = found; + } else if (i == found) { + sb.push(s.substring(i, i + 1)); + i += 1; + found = s.indexOf("ı", i); + } else { + sb.push(s.substring(i).toUpperCase()); // Append upper remaining + break; + } + } + + return sb.join(""); +} diff --git a/actions-expressions/src/xlang.test.ts b/actions-expressions/src/xlang.test.ts new file mode 100644 index 0000000..9853a97 --- /dev/null +++ b/actions-expressions/src/xlang.test.ts @@ -0,0 +1,198 @@ +import * as fs from "fs"; +import * as path from "path"; +import { Expr } from "./ast"; +import * as data from "./data"; +import { kindStr } from "./data/expressiondata"; +import { replacer } from "./data/replacer"; +import { reviver } from "./data/reviver"; +import { ExpressionError } from "./errors"; +import { Evaluator } from "./evaluator"; +import { Lexer, Result } from "./lexer"; +import { Parser } from "./parser"; + +interface TestResult { + value: data.ExpressionData; + kind: string; +} + +enum TestErrorKind { + Lexing = "lexing", + Parsing = "parsing", + Evaluation = "evaluation", +} + +enum SkipOptionKind { + Typescript = "typescript", +} + +interface TestOptions { + skip: SkipOptionKind[]; +} + +interface TestError { + value: string; + kind: TestErrorKind; +} + +interface TestCase { + expr: string; + result: TestResult; + err: TestError | undefined; + contexts: data.Dictionary; + options: TestOptions; +} + +function testCaseReviver(key: string, val: any): any { + if (key === "contexts") { + const tmp = JSON.stringify(val); + return JSON.parse(tmp, reviver); + } + + if (key === "result") { + const t = val as TestResult; + t.value = reviver("value", t.value); + } + + return val; +} + +const testFiles = "./testData"; + +describe("x-lang tests", () => { + const files = fs.readdirSync(testFiles); + + for (const file of files) { + const fileName = path.join(testFiles, file); + + const fileStat = fs.statSync(fileName); + if (fileStat.isDirectory()) { + throw new Error("sub-directories are not supported"); + } + + if (path.extname(fileName) !== ".json") { + throw new Error("only json files are supported " + file); + } + + const testFile = fs.readFileSync(fileName, "utf8"); + + const testCases = JSON.parse(testFile, testCaseReviver) as { + [name: string]: TestCase[]; + }; + + for (const testCaseName of Object.keys(testCases)) { + let tests = testCases[testCaseName]; + + // Filter out tests that are not supported by typescript + tests = tests.filter( + (t) => !t.options?.skip?.includes(SkipOptionKind.Typescript) + ); + + const testName = path.basename(file, ".json"); + + if (tests.length === 0) { + continue; + } + + describe(testName, () => { + test.each(tests)(`${testName}: ${testCaseName} ($expr)`, (testCase) => { + if (!testCase.contexts) { + testCase.contexts = new data.Dictionary(); + } + + const lexer = new Lexer(testCase.expr); + let result: Result; + try { + result = lexer.lex(); + + if (testCase.err && testCase.err.kind === TestErrorKind.Lexing) { + throw new Error("expected error lexing expression, but got none"); + } + } catch (e: any) { + // Did test expect lexing error? If so, compare error message. + if (testCase.err && testCase.err.kind === TestErrorKind.Lexing) { + expect(e.message).toContain(testCase.err.value); + return; + } + + throw new Error( + `unexpected error lexing expression: ${e.message} ${e.stack}` + ); + } + + // Parse + const contextNames = testCase.contexts.pairs().map((x) => x.key); + const parser = new Parser(result.tokens, contextNames, []); + let expr: Expr; + try { + expr = parser.parse(); + + if (testCase.err?.kind === TestErrorKind.Parsing) { + throw new Error( + "expected error parsing expression, but got none" + ); + } + } catch (e: any) { + // Did test expect parsing error? + if (testCase.err?.kind === TestErrorKind.Parsing) { + // Test expects parsing error + const pe = e as ExpressionError; + expect(errorWithExpression(pe, testCase.expr)).toContain( + testCase.err.value + ); + return; + } + + throw new Error( + `unexpected error parsing expression: ${e.message} ${e.stack}` + ); + } + + // Evaluate expression + try { + let result: data.ExpressionData; + if (expr !== undefined) { + const evaluator = new Evaluator(expr, testCase.contexts); + result = evaluator.evaluate(); + } else { + result = new data.Null(); + } + + if (testCase.err?.kind === TestErrorKind.Evaluation) { + throw new Error( + "expected error evaluating expression, but got none" + ); + } + + expect(kindStr(result.kind)).toBe(testCase.result.kind); + + expect(JSON.stringify(result, replacer)).toEqual( + JSON.stringify(testCase.result.value, replacer) + ); + } catch (e: any) { + if (testCase.err?.kind === TestErrorKind.Evaluation) { + const pe = e as ExpressionError; + expect(errorWithExpression(pe, testCase.expr)).toContain( + testCase.err.value + ); + return; + } + + throw new Error( + `unexpected error evaluating expression: ${e.message} ${e.stack}` + ); + } + }); + }); + } + } +}); + +function errorWithExpression(e: ExpressionError, expr: string): string { + if (e.pos !== undefined) { + return `${e.message}. Located at position ${ + e.pos.column + 1 + } within expression: ${expr}`; + } + + return `${e.message}. Located within expression: ${expr}`; +} diff --git a/actions-expressions/testdata/basic.json b/actions-expressions/testdata/basic.json new file mode 100644 index 0000000..87c7325 --- /dev/null +++ b/actions-expressions/testdata/basic.json @@ -0,0 +1,64 @@ +{ + "empty_expression": [{ + "expr": "", + "result": { + "kind": "Null", + "value": null + } + }], + "equal_simple": [{ + "expr": "1 == 2", + "result": { + "kind": "Boolean", + "value": false + } + }], + "context_simple_access": [{ + "expr": "simple", + "result": { + "kind": "String", + "value": "foo" + }, + "contexts": { + "simple": "foo" + } + }], + "context_case-insensitive": [{ + "expr": "SIMple.TEst", + "result": { + "kind": "Number", + "value": 123.0 + }, + "contexts": { + "simPLE": { + "teST": 123 + } + } + }], + "context access with wildcard": [{ + "expr": "toJson(input.*.foo)", + "result": { + "kind": "String", + "value": "[\n 32,\n 42,\n -10,\n 0,\n 2,\n 17\n]" + }, + "contexts": { + "input": { + "test": { "foo": 32 }, + "test2": { "foo": 42 }, + "test3": { "foo": -10 }, + "test4": { "foo": 0 }, + "test5": { "foo": 2 }, + "test6": { "foo": 17 } + } + } + }], + "unknown context": [ + { + "expr": "nosuchcontext.foo", + "err": { + "kind": "parsing", + "value": "Unrecognized named-value: 'nosuchcontext'. Located at position 1 within expression: nosuchcontext.foo" + } + } + ] +} diff --git a/actions-expressions/testdata/coerce_boolean.json b/actions-expressions/testdata/coerce_boolean.json new file mode 100644 index 0000000..528b737 --- /dev/null +++ b/actions-expressions/testdata/coerce_boolean.json @@ -0,0 +1,4 @@ +{ + "refer ./operator_not.json": [ + ] +} \ No newline at end of file diff --git a/actions-expressions/testdata/coerce_number.json b/actions-expressions/testdata/coerce_number.json new file mode 100644 index 0000000..96d04d6 --- /dev/null +++ b/actions-expressions/testdata/coerce_number.json @@ -0,0 +1,124 @@ +{ + "null": [ + { + "expr": "null == 0", + "result": { "kind": "Boolean", "value": true } + } + ], + "boolean": [ + { + "expr": "true == 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "false == 0", + "result": { "kind": "Boolean", "value": true } + } + ], + "string": [ + { + "expr": "'' == 0", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 123456.789 ' == 123456.789", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' +123456.789 ' == 123456.789", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' -123456.789 ' == -123456.789", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 0xff ' == 255", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 0xFF ' == 255", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 0o10 ' == 8", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' Infinity ' == Infinity", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' -Infinity ' == -Infinity", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' NaN ' != NaN", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' NaN ' == NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' NaN ' > NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' NaN ' < NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' abc ' != NaN", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' abc ' == NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' abc ' > NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' abc ' < NaN", + "result": { "kind": "Boolean", "value": false } + } + ], + "array": [ + { + "expr": "fromjson('[]') != NaN", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "fromjson('[]') == NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "fromjson('[]') > NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "fromjson('[]') < NaN", + "result": { "kind": "Boolean", "value": false } + } + ], + "object": [ + { + "expr": "fromjson('[]') != NaN", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "fromjson('[]') == NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "fromjson('[]') > NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "fromjson('[]') < NaN", + "result": { "kind": "Boolean", "value": false } + } + ] +} \ No newline at end of file diff --git a/actions-expressions/testdata/coerce_string.json b/actions-expressions/testdata/coerce_string.json new file mode 100644 index 0000000..3003e06 --- /dev/null +++ b/actions-expressions/testdata/coerce_string.json @@ -0,0 +1,178 @@ +{ + "null": [ + { + "expr": "format('{0}', null)", + "result": { "kind": "String", "value": "" } + } + ], + "boolean": [ + { + "expr": "format('{0}', true)", + "result": { "kind": "String", "value": "true" } + }, + { + "expr": "format('{0}', false)", + "result": { "kind": "String", "value": "false" } + } + ], + "number": [ + { + "expr": "format('{0}', 1)", + "result": { "kind": "String", "value": "1" } + }, + { + "expr": "format('{0}', .5)", + "result": { "kind": "String", "value": "0.5" } + }, + { + "expr": "format('{0}', 0.5)", + "result": { "kind": "String", "value": "0.5" } + }, + { + "expr": "format('{0}', 2)", + "result": { "kind": "String", "value": "2" } + }, + { + "expr": "format('{0}', -1)", + "result": { "kind": "String", "value": "-1" } + }, + { + "expr": "format('{0}', -.5)", + "result": { "kind": "String", "value": "-0.5" } + }, + { + "expr": "format('{0}', -0.5)", + "result": { "kind": "String", "value": "-0.5" } + }, + { + "expr": "format('{0}', -2.0)", + "result": { "kind": "String", "value": "-2" } + }, + { + "expr": "format('{0}', 0)", + "result": { "kind": "String", "value": "0" } + }, + { + "expr": "format('{0}', 0.0)", + "result": { "kind": "String", "value": "0" } + }, + { + "expr": "format('{0}', -0)", + "result": { "kind": "String", "value": "0" } + }, + { + "expr": "format('{0}', -0.0)", + "result": { "kind": "String", "value": "0" } + }, + { + "expr": "format('{0}', 123456.789)", + "result": { "kind": "String", "value": "123456.789" } + }, + { + "expr": "format('{0}', +123456.789)", + "result": { "kind": "String", "value": "123456.789" } + }, + { + "expr": "format('{0}', -123456.789)", + "result": { "kind": "String", "value": "-123456.789" } + }, + { + "expr": "format('{0}', 0.84551240822557006)", + "result": { "kind": "String", "value": "0.84551240822557" } + }, + { + "expr": "format('{0}', 1.9)", + "result": { "kind": "String", "value": "1.9" } + }, + { + "expr": "format('{0}', 1234.56)", + "result": { "kind": "String", "value": "1234.56" } + }, + { + "expr": "format('{0}', 0xff)", + "result": { "kind": "String", "value": "255" } + }, + { + "expr": "format('{0}', 0xFF)", + "result": { "kind": "String", "value": "255" } + }, + { + "expr": "format('{0}', 0o10)", + "result": { "kind": "String", "value": "8" } + }, + { + "expr": "format('{0}', 1.2e2)", + "result": { "kind": "String", "value": "120" } + }, + { + "expr": "format('{0}', 1.2E2)", + "result": { "kind": "String", "value": "120" } + }, + { + "expr": "format('{0}', 1.2e+2)", + "result": { "kind": "String", "value": "120" } + }, + { + "expr": "format('{0}', 1.2E+2)", + "result": { "kind": "String", "value": "120" } + }, + { + "expr": "format('{0}', 1.2e-2)", + "result": { "kind": "String", "value": "0.012" } + }, + { + "expr": "format('{0}', 1.2E-2)", + "result": { "kind": "String", "value": "0.012" } + }, + { + "expr": "format('{0}', Infinity)", + "result": { "kind": "String", "value": "Infinity" } + }, + { + "expr": "format('{0}', -Infinity)", + "result": { "kind": "String", "value": "-Infinity" } + }, + { + "expr": "format('{0}', NaN)", + "result": { "kind": "String", "value": "NaN" } + } + ], + "string": [ + { + "expr": "format('{0}', 'string value')", + "result": { "kind": "String", "value": "string value" } + } + ], + "array": [ + { + "expr": "format('{0}', context)", + "contexts": { + "context": [] + }, + "result": { "kind": "String", "value": "Array" } + }, + { + "expr": "format('{0}', context)", + "contexts": { + "context": [1, 2, 3] + }, + "result": { "kind": "String", "value": "Array" } + } + ], + "object": [ + { + "expr": "format('{0}', context)", + "contexts": { + "context": {} + }, + "result": { "kind": "String", "value": "Object" } + }, + { + "expr": "format('{0}', context)", + "contexts": { + "context": { "a": 1, "b": 2, "c": 3 } + }, + "result": { "kind": "String", "value": "Object" } + } + ] +} \ No newline at end of file diff --git a/actions-expressions/testdata/contains.json b/actions-expressions/testdata/contains.json new file mode 100644 index 0000000..a0d8e4a --- /dev/null +++ b/actions-expressions/testdata/contains.json @@ -0,0 +1,122 @@ +{ + "string": [ + { + "expr": "contains('abcdef', 'cde')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "contains('abCdEf', 'cDe')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "contains('abcdef', '')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "contains('1234', 23)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "contains('true', true)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "contains('false', false)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "contains('asdf', null)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "contains('true', false)", + "result": { "kind": "Boolean", "value": false } + } + ], + + "array": [ + { + "expr": "contains(test, 'hello')", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": ["hello"] } + }, + { + "expr": "contains(test, 'world')", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": ["hello", "world"] } + }, + { + "expr": "contains(test, 'asdf')", + "result": { "kind": "Boolean", "value": false }, + "contexts": { "test": ["hello"] } + }, + { + "expr": "contains(test, 123)", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": [123.0] } + }, + { + "expr": "contains(test, 123)", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": [123.0] } + }, + { + "expr": "contains(test, null)", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": [""] } + }, + { + "expr": "contains(test, false)", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": [""] } + }, + { + "expr": "contains(test, false)", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": [0] } + } + ], + + "filtered_array": [ + { + "expr": "contains(test.*, 'hello')", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": ["hello"] } + }, + { + "expr": "contains(test.*, 'world')", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": ["hello", "world"] } + }, + { + "expr": "contains(test.*, 'asdf')", + "result": { "kind": "Boolean", "value": false }, + "contexts": { "test": ["hello"] } + }, + { + "expr": "contains(test.*, 123)", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": [123.0] } + }, + { + "expr": "contains(test.*, 123)", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": [123.0] } + }, + { + "expr": "contains(test.*, null)", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": [""] } + }, + { + "expr": "contains(test.*, false)", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": [""] } + }, + { + "expr": "contains(test.*, false)", + "result": { "kind": "Boolean", "value": true }, + "contexts": { "test": [0] } + } + ] +} diff --git a/actions-expressions/testdata/endsWith.json b/actions-expressions/testdata/endsWith.json new file mode 100644 index 0000000..28804f1 --- /dev/null +++ b/actions-expressions/testdata/endsWith.json @@ -0,0 +1,129 @@ +{ + "basics": [ + { + "expr": "endsWith('abcdef', 'def')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('abcdef', 'de')", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "endsWith('abcdef', '')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('1234', 34)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('true', true)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('false', false)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('asdf', null)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('1234', 23)", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "endsWith('true', false)", + "result": { "kind": "Boolean", "value": false } + } + ], + + "case-insensitive-a-thru-z": [ + { + "expr": "endsWith('asdf_ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('asdf_abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-insensitive-ς-(final-lowercase-sigma)-Σ-(capital-sigma)-σ-(non-final-sigma)": [ + { + "expr": "endsWith('asdf_ς', 'Σ')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('asdf_ς', 'σ')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('asdf_Σ', 'ς')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('asdf_Σ', 'σ')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('asdf_σ', 'ς')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('asdf_σ', 'Σ')", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-insensitive-ü-Ü": [ + { + "expr": "endsWith('asdf_ü', 'Ü')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('asdf_Ü', 'ü')", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-insensitive-ç-Ç": [ + { + "expr": "endsWith('asdf_ç', 'Ç')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "endsWith('asdf_Ç', 'ç')", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-sensitive-i-İ": [ + { + "expr": "endsWith('asdf_i', 'İ')", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "endsWith('asdf_İ', 'i')", + "result": { "kind": "Boolean", "value": false } + } + ], + + "case-sensitive-ı-I": [ + { + "expr": "endsWith('asdf_ı', 'I')", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "endsWith('asdf_I', 'ı')", + "result": { "kind": "Boolean", "value": false } + } + ], + + "cyrillic-letters": [ + { + "expr": "endsWith('asdf_АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЬЭЮЯ', 'абвгдежзийклмнопрстуфхцчшщьэюя')", + "result": { "kind": "Boolean", "value": true } + } + ] +} diff --git a/actions-expressions/testdata/format.json b/actions-expressions/testdata/format.json new file mode 100644 index 0000000..24dc64f --- /dev/null +++ b/actions-expressions/testdata/format.json @@ -0,0 +1,207 @@ +{ + "format": [ + { + "expr": "format(null)", + "result": { "kind": "String", "value": "" } + }, + { + "expr": "format(null, 'some arg')", + "result": { "kind": "String", "value": "" } + }, + { + "expr": "format('')", + "result": { "kind": "String", "value": "" } + }, + { + "expr": "format('', 'some arg')", + "result": { "kind": "String", "value": "" } + }, + { + "expr": "format('123{0}456', 'abc')", + "result": { "kind": "String", "value": "123abc456" } + }, + { + "expr": "format('123{0}456{0}789', 'abc')", + "result": { "kind": "String", "value": "123abc456abc789" } + }, + { + "expr": "format('123{0}456{1}789', 'abc', 'def')", + "result": { "kind": "String", "value": "123abc456def789" } + }, + { + "expr": "format('{0}123', 'abc')", + "result": { "kind": "String", "value": "abc123" } + }, + { + "expr": "format('123{0}', 'abc')", + "result": { "kind": "String", "value": "123abc" } + }, + { + "expr": "format('123{0}{1}456', 'abc', 'def')", + "result": { "kind": "String", "value": "123abcdef456" } + }, + { + "expr": "format('{{0}}', 'abc')", + "result": { "kind": "String", "value": "{0}" } + }, + { + "expr": "format('{{{{0}}}}', 'abc')", + "result": { "kind": "String", "value": "{{0}}" } + }, + { + "expr": "format('}}', 'abc')", + "result": { "kind": "String", "value": "}" } + }, + { + "expr": "format('{{', 'abc')", + "result": { "kind": "String", "value": "{" } + }, + { + "expr": "format('}}{{', 'abc')", + "result": { "kind": "String", "value": "}{" } + }, + { + "expr": "format('}}{{}}', 'abc')", + "result": { "kind": "String", "value": "}{}" } + }, + { + "expr": "format('{0}', Infinity)", + "result": { "kind": "String", "value": "Infinity" } + }, + { + "expr": "format('{0}', -Infinity)", + "result": { "kind": "String", "value": "-Infinity" } + }, + { + "expr": "format('{0}', NaN)", + "result": { "kind": "String", "value": "NaN" } + }, + { + "expr": "format('{0}', -0)", + "result": { "kind": "String", "value": "0" } + }, + { + "expr": "format('{0}', -0.0)", + "result": { "kind": "String", "value": "0" } + }, + { + "expr": "format('{0')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: {0" + } + }, + { + "expr": "format('{0', '')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: {0" + } + }, + { + "expr": "format('{0}}', '')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: {0}}" + } + }, + { + "expr": "format('{0}}}}', '')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: {0}}}}" + } + }, + { + "expr": "format('0}')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: 0}" + } + }, + { + "expr": "format('0}', '')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: 0}" + } + }, + { + "expr": "format('{{0}')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: {{0}" + } + }, + { + "expr": "format('{{0}', '')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: {{0}" + } + }, + { + "expr": "format('{{{{0}')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: {{{{0}" + } + }, + { + "expr": "format('{{{{0}', '')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: {{{{0}" + } + }, + { + "expr": "format('}0{')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: }0{" + } + }, + { + "expr": "format('}0{', '')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: }0{" + } + }, + { + "expr": "format('}{0}')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: }{0}" + } + }, + { + "expr": "format('}{0}', '')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: }{0}" + } + }, + { + "expr": "format('{0}{', '')", + "err": { + "kind": "evaluation", + "value": "The following format string is invalid: {0}{" + } + }, + { + "expr": "format('{0}')", + "err": { + "kind": "evaluation", + "value": "The following format string references more arguments than were supplied: {0}" + } + }, + { + "expr": "format('{0}{1}', 'abc')", + "err": { + "kind": "evaluation", + "value": "The following format string references more arguments than were supplied: {0}{1}" + } + } + ] +} diff --git a/actions-expressions/testdata/fromJSON.json b/actions-expressions/testdata/fromJSON.json new file mode 100644 index 0000000..16591e3 --- /dev/null +++ b/actions-expressions/testdata/fromJSON.json @@ -0,0 +1,67 @@ +{ + "basics": [ + { "expr": "fromJSON('null')", "result": { "kind": "Null", "value": null } }, + { "expr": "fromJSON('true')", "result": { "kind": "Boolean", "value": true } }, + { "expr": "fromJSON('false')", "result": { "kind": "Boolean", "value": false } }, + { "expr": "fromJSON('0')", "result": { "kind": "Number", "value": 0 } }, + { "expr": "fromJSON('-0')", "result": { "kind": "Number", "value": -0 } }, + { "expr": "fromJSON('123456789')", "result": { "kind": "Number", "value": 123456789 } }, + { "expr": "fromJSON('-123456789')", "result": { "kind": "Number", "value": -123456789 } }, + { "expr": "fromJSON('1234.5')", "result": { "kind": "Number", "value": 1234.5 } }, + { "expr": "fromJSON('-1234.5')", "result": { "kind": "Number", "value": -1234.5 } }, + { "expr": "fromJSON('\"\"')", "result": { "kind": "String", "value": "" } }, + { "expr": "fromJSON('\"abc\"')", "result": { "kind": "String", "value": "abc" } }, + { "expr": "fromJSON('\"abc''def\"')", "result": { "kind": "String", "value": "abc'def" } }, + { "expr": "fromJSON('\"abc\\\\\\\"def\"')", "result": { "kind": "String", "value": "abc\\\"def" } } + ], + "array": [ + { "expr": "fromJSON('[]')", "result": { "kind": "Array", "value": [] } }, + { "expr": "fromJSON('[1, 2, 3]')", "result": { "kind": "Array", "value": [1, 2, 3] } }, + { + "expr": "fromJSON('[[1, 2, 3], [\"abc\",\"def\",\"ghi\"], [true, false, null, [], {}]]')", + "result": { + "kind": "Array", + "value": [[1, 2, 3], ["abc", "def", "ghi"], [true, false, null, [], {}]] + } + } + ], + "object": [ + { "expr": "fromJSON('{}')", "result": { "kind": "Object", "value": {} } }, + { + "expr": "fromJSON('{\"one\": \"value one\", \"two\": \"value two\", \"three\": \"value three\"}')", + "result": { + "kind": "Object", + "value": { + "one": "value one", + "two": "value two", + "three": "value three" + } + } + }, + { + "expr": "fromJSON('{\"nested-one\": {\"one\": 1,\"two\": 2,\"three\": 3},\"nested-two\": {\"string one\": \"value one\",\"string two\": \"value two\",\"string three\": \"value three\"},\"nested-three\": {\"true\": true,\"false\": false,\"null\": null,\"array\": [],\"object\": {}}\n}')", + "result": { + "kind": "Object", + "value": { + "nested-one": { + "one": 1, + "two": 2, + "three": 3 + }, + "nested-two": { + "string one": "value one", + "string two": "value two", + "string three": "value three" + }, + "nested-three": { + "true": true, + "false": false, + "null": null, + "array": [], + "object": {} + } + } + } + } + ] +} \ No newline at end of file diff --git a/actions-expressions/testdata/join.json b/actions-expressions/testdata/join.json new file mode 100644 index 0000000..e1063a0 --- /dev/null +++ b/actions-expressions/testdata/join.json @@ -0,0 +1,113 @@ +{ + "join": [ + { + "expr": "join(github.event.issue.labels.*.name, ', ')", + "contexts": { + "github": { + "event": { + "issue": { + "labels": [ + { + "name": "bug" + }, + { + "name": "enhancement" + }, + { + "name": "help wanted" + } + ] + } + } + } + }, + "result": { "kind": "String", "value": "bug, enhancement, help wanted" } + }, + { + "expr": "join(null)", + "result": { "kind": "String", "value": "" } + }, + { + "expr": "join(true)", + "result": { "kind": "String", "value": "true" } + }, + { + "expr": "join(123.456)", + "result": { "kind": "String", "value": "123.456" } + }, + { + "expr": "join('abc')", + "result": { "kind": "String", "value": "abc" } + }, + { + "expr": "join(myArray)", + "contexts": { "myArray": [] }, + "result": { "kind": "String", "value": "" } + }, + { + "expr": "join(myObject)", + "contexts": { "myObject": {} }, + "result": { "kind": "String", "value": "" } + }, + { + "expr": "join(myObject)", + "contexts": { "myObject": { "key1": "value1" } }, + "result": { "kind": "String", "value": "" } + }, + { + "expr": "join(myArray)", + "contexts": { "myArray": [ null, true, 123.456, "abc", [], ["def"], {}, { "key1": "value1" } ] }, + "result": { "kind": "String", "value": ",true,123.456,abc,Array,Array,Object,Object" } + }, + { + "expr": "join(myArray, null)", + "contexts": { "myArray": [ "_ITEM-1_", "_ITEM-2_", "_ITEM-3_" ] }, + "result": { "kind": "String", "value": "_ITEM-1__ITEM-2__ITEM-3_" } + }, + { + "expr": "join(myArray, true)", + "contexts": { "myArray": [ "_ITEM-1_", "_ITEM-2_", "_ITEM-3_" ] }, + "result": { "kind": "String", "value": "_ITEM-1_true_ITEM-2_true_ITEM-3_" } + }, + { + "expr": "join(myArray, 123.456)", + "contexts": { "myArray": [ "_ITEM-1_", "_ITEM-2_", "_ITEM-3_" ] }, + "result": { "kind": "String", "value": "_ITEM-1_123.456_ITEM-2_123.456_ITEM-3_" } + }, + { + "expr": "join(myArray, ' | ')", + "contexts": { "myArray": [ "_ITEM-1_", "_ITEM-2_", "_ITEM-3_" ] }, + "result": { "kind": "String", "value": "_ITEM-1_ | _ITEM-2_ | _ITEM-3_" } + }, + { + "expr": "join(myArray, mySeparator)", + "contexts": { + "myArray": [ "_ITEM-1_", "_ITEM-2_", "_ITEM-3_" ], + "mySeparator": [] + }, + "result": { "kind": "String", "value": "_ITEM-1_,_ITEM-2_,_ITEM-3_" } + }, + { + "expr": "join(myArray, mySeparator)", + "contexts": { + "myArray": [ "_ITEM-1_", "_ITEM-2_", "_ITEM-3_" ], + "mySeparator": {} + }, + "result": { "kind": "String", "value": "_ITEM-1_,_ITEM-2_,_ITEM-3_" } + }, + { + "expr": "join()", + "err": { + "kind": "parsing", + "value": "Too few parameters supplied: " + } + }, + { + "expr": "join(1, 2, 3)", + "err": { + "kind": "parsing", + "value": "Too many parameters supplied: " + } + } + ] +} diff --git a/actions-expressions/testdata/number.json b/actions-expressions/testdata/number.json new file mode 100644 index 0000000..9d44841 --- /dev/null +++ b/actions-expressions/testdata/number.json @@ -0,0 +1,51 @@ +{ + "number": [ + { "expr": "1", "result": { "kind": "Number", "value": 1.0 } }, + { "expr": ".5", "result": { "kind": "Number", "value": 0.5 } }, + { "expr": "0.5", "result": { "kind": "Number", "value": 0.5 } }, + { "expr": "2", "result": { "kind": "Number", "value": 2.0 } }, + { "expr": "-1", "result": { "kind": "Number", "value": -1.0 } }, + { "expr": "+1", "result": { "kind": "Number", "value": 1.0 } }, + { "expr": "-.5", "result": { "kind": "Number", "value": -0.5 } }, + { "expr": "-2", "result": { "kind": "Number", "value": -2.0 } }, + { "expr": "format('{0}', -Infinity)", "result": { "kind": "String", "value": "-Infinity" } }, + { "expr": "format('{0}', Infinity)", "result": { "kind": "String", "value": "Infinity" } }, + { "expr": "format('{0}', +Infinity)", "result": { "kind": "String", "value": "Infinity" } }, + { "expr": "format('{0}', NaN)", "result": { "kind": "String", "value": "NaN" } }, + { "expr": "0", "result": { "kind": "Number", "value": 0.0 } }, + { "expr": "0.0", "result": { "kind": "Number", "value": 0.0 } }, + { "expr": "-0", "result": { "kind": "Number", "value": -0.0 } }, + { "expr": "-0.0", "result": { "kind": "Number", "value": -0.0 } }, + { "expr": "0x0", "result": { "kind": "Number", "value": 0.0 } }, + { "expr": "0x00", "result": { "kind": "Number", "value": 0.0 } }, + { "expr": "0xf", "result": { "kind": "Number", "value": 15.0 } }, + { "expr": "0xfF", "result": { "kind": "Number", "value": 255.0 } }, + { "expr": "0xfFf", "result": { "kind": "Number", "value": 4095.0 } }, + { "expr": "0o0", "result": { "kind": "Number", "value": 0.0 } }, + { "expr": "0o7", "result": { "kind": "Number", "value": 7.0 } }, + { "expr": "0o77", "result": { "kind": "Number", "value": 63.0 } }, + { "expr": "0o777", "result": { "kind": "Number", "value": 511.0 } }, + { "expr": "1e1", "result": { "kind": "Number", "value": 10.0 } }, + { "expr": "1e2", "result": { "kind": "Number", "value": 100.0 } }, + { "expr": "1E1", "result": { "kind": "Number", "value": 10.0 } }, + { "expr": "1E+1", "result": { "kind": "Number", "value": 10.0 } }, + { "expr": "1e-1", "result": { "kind": "Number", "value": 0.1 } }, + { "expr": "1E-1", "result": { "kind": "Number", "value": 0.1 } }, + { + "expr": "0x01p2", + "err": { "kind": "lexing", "value": "Unexpected symbol: '0x01p2'. Located at position 1 within expression: 0x01p2" } + }, + { + "expr": "-Inf", + "err": { "kind": "lexing", "value": "Unexpected symbol: '-Inf'. Located at position 1 within expression: -Inf" } + }, + { + "expr": "-0xFF", + "err": { "kind": "lexing", "value": "Unexpected symbol: '-0xFF'. Located at position 1 within expression: -0xFF" } + }, + { + "expr": "0xFZ", + "err": { "kind": "lexing", "value": "Unexpected symbol: '0xFZ'. Located at position 1 within expression: 0xFZ" } + } + ] +} diff --git a/actions-expressions/testdata/op_and.json b/actions-expressions/testdata/op_and.json new file mode 100644 index 0000000..3de171d --- /dev/null +++ b/actions-expressions/testdata/op_and.json @@ -0,0 +1,51 @@ +{ + "operator_and": [ + { + "expr": "true && true && true", + "result": { "kind": "Boolean", "value": true } + }, + { "expr": "true && true", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "true && true && false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true && false && true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false && true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false && false", + "result": { "kind": "Boolean", "value": false } + }, + { "expr": "true && 1 && 2", "result": { "kind": "Number", "value": 2.0 } }, + { "expr": "true && 0 && 2", "result": { "kind": "Number", "value": 0.0 } }, + { + "expr": "true && 'a' && 'b'", + "result": { "kind": "String", "value": "b" } + }, + { + "expr": "true && '' && 'asdf'", + "result": { "kind": "String", "value": "" } + }, + { "expr": "null && true", "result": { "kind": "Null", "value": null } }, + { + "expr": "test && 123", + "contexts": { "test": {} }, + "result": { "kind": "Number", "value": 123.0 } + }, + { + "expr": "test && 456", + "contexts": { "test": {} }, + "result": { "kind": "Number", "value": 456.0 } + }, + { + "expr": "test && 789", + "contexts": { "test": [] }, + "result": { "kind": "Number", "value": 789.0 } + } + ] +} diff --git a/actions-expressions/testdata/op_dot.json b/actions-expressions/testdata/op_dot.json new file mode 100644 index 0000000..786ea6d --- /dev/null +++ b/actions-expressions/testdata/op_dot.json @@ -0,0 +1,202 @@ +{ + "property-basics": [ + { + "expr": "foo.bar", + "contexts": { + "foo": { + "bar": "baz" + } + }, + "result": { "kind": "String", "value": "baz" } + }, + { + "expr": "foo.Bar", + "contexts": { + "foo": { + "Bar": "baz" + } + }, + "result": { "kind": "String", "value": "baz" } + }, + { + "expr": "foo.b", + "contexts": { + "foo": { + "b": "baz" + } + }, + "result": { "kind": "String", "value": "baz" } + }, + { + "expr": "foo._", + "contexts": { + "foo": { + "_": "baz" + } + }, + "result": { "kind": "String", "value": "baz" } + }, + { + "expr": "foo._bar", + "contexts": { + "foo": { + "_bar": "baz" + } + }, + "result": { "kind": "String", "value": "baz" } + }, + { + "expr": "foo.b_ar", + "contexts": { + "foo": { + "b_ar": "baz" + } + }, + "result": { "kind": "String", "value": "baz" } + }, + { + "expr": "foo.b-ar", + "contexts": { + "foo": { + "b-ar": "baz" + } + }, + "result": { "kind": "String", "value": "baz" } + }, + { + "expr": "fromJson('{\"one\": \"one val\"}').one", + "result": { "kind": "String", "value": "one val" } + }, + { + "expr": "(fromJson('{\"one\": \"one val\"}')).one", + "result": { "kind": "String", "value": "one val" } + }, + { + "expr": "foo[*]", + "contexts": { + "foo": { + "one": "one val", + "two": "two val" + } + }, + "result": { "kind": "Array", "value": ["one val", "two val"] } + } + ], + "property-case-insensitive": [ + { + "expr": "foo.bar", + "contexts": { + "foo": { + "BAR": "baz" + } + }, + "result": { "kind": "String", "value": "baz" } + }, + { + "expr": "foo.BAR", + "contexts": { + "foo": { + "bar": "baz" + } + }, + "result": { "kind": "String", "value": "baz" } + } + ], + "property-matches-const": [ + { + "expr": "foo.true", + "contexts": { + "foo": { + "true": "it's true" + } + }, + "result": { "kind": "String", "value": "it's true" } + }, + { + "expr": "foo.false", + "contexts": { + "foo": { + "false": "it's false" + } + }, + "result": { "kind": "String", "value": "it's false" } + }, + { + "expr": "foo.Infinity", + "contexts": { + "foo": { + "Infinity": "it's Infinity" + } + }, + "result": { "kind": "String", "value": "it's Infinity" } + }, + { + "expr": "foo.NaN", + "contexts": { + "foo": { + "NaN": "it's NaN" + } + }, + "result": { "kind": "String", "value": "it's NaN" } + }, + { + "expr": "foo.null", + "contexts": { + "foo": { + "null": "it's null" + } + }, + "result": { "kind": "String", "value": "it's null" } + }, + { + "expr": "foo.format", + "contexts": { + "foo": { + "format": "it's format" + } + }, + "result": { "kind": "String", "value": "it's format" } + } + ], + "property-errors": [ + { + "expr": "foo.b@r", + "contexts": { + "foo": { + "b@r": "baz" + } + }, + "err": { + "kind": "lexing", + "value": "Unexpected symbol: 'b@r'. Located at position 5 within expression: foo.b@r" + } + }, + { + "expr": "foo.1", + "contexts": { + "foo": {} + }, + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '1'. Located at position 5 within expression: foo.1" + } + }, + { + "expr": "fromjson('').1", + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '1'. Located at position 14 within expression: fromjson('').1" + } + }, + { + "expr": "foo[1].2", + "contexts": { + "foo": {} + }, + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '2'. Located at position 8 within expression: foo[1].2" + } + } + ] +} diff --git a/actions-expressions/testdata/op_eq.json b/actions-expressions/testdata/op_eq.json new file mode 100644 index 0000000..89ca1e8 --- /dev/null +++ b/actions-expressions/testdata/op_eq.json @@ -0,0 +1,430 @@ +{ + "boolean": [ + { "expr": "true == true", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "false == false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "false == true", + "result": { "kind": "Boolean", "value": false } + } + ], + + "number": [ + { "expr": "0 == -0", "result": { "kind": "Boolean", "value": true } }, + { "expr": "2 == 2", "result": { "kind": "Boolean", "value": true } }, + { "expr": "1.001 == 1.002", "result": { "kind": "Boolean", "value": false } }, + { "expr": "120 == 1.2e2", "result": { "kind": "Boolean", "value": true } }, + { "expr": "1 == 2", "result": { "kind": "Boolean", "value": false } }, + { "expr": "NaN == NaN", "result": { "kind": "Boolean", "value": false } }, + { + "expr": "Infinity == Infinity", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "-Infinity == Infinity", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "0 == Infinity", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 == Infinity", + "result": { "kind": "Boolean", "value": false } + } + ], + + "string": [ + { "expr": "'a' == 'a'", "result": { "kind": "Boolean", "value": true } }, + { "expr": "'a' == 'b'", "result": { "kind": "Boolean", "value": false } } + ], + + "null": [ + { "expr": "null == null", "result": { "kind": "Boolean", "value": true } } + ], + + "object": [ + { + "expr": "object1 == object1", + "contexts": { "object1": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "object1 == object2", + "contexts": { "object1": {}, "object2": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "array": [ + { + "expr": "array1 == array1", + "contexts": { "array1": [] }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "array1 == array2", + "contexts": { "array1": [], "array2": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_bool_number": [ + { "expr": "false == 0", "result": { "kind": "Boolean", "value": true } }, + { "expr": "true == 1", "result": { "kind": "Boolean", "value": true } }, + { "expr": "false == NaN", "result": { "kind": "Boolean", "value": false } }, + { "expr": "true == 2", "result": { "kind": "Boolean", "value": false } } + ], + + "coerce_bool_string": [ + { "expr": "false == ''", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "false == ' '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "false == ' 0.0 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true == ' 1.0 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true == ' 1 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "false == '-1'", + "result": { "kind": "Boolean", "value": false } + }, + { "expr": "true == '2'", "result": { "kind": "Boolean", "value": false } } + ], + + "coerce_bool_null": [ + { + "expr": "false == null", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true == null", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_bool_object-array": [ + { + "expr": "false == test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false == test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true == test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true == test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_number_bool": [ + { "expr": "0 == false", "result": { "kind": "Boolean", "value": true } }, + { "expr": "1 == true", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "NaN == false", + "result": { "kind": "Boolean", "value": false } + }, + { "expr": "2 == true", "result": { "kind": "Boolean", "value": false } } + ], + + "coerce_number_string": [ + { + "expr": "0 == ' -0.0 '", + "result": { "kind": "Boolean", "value": true } + }, + { "expr": "0 == ''", "result": { "kind": "Boolean", "value": true } }, + { "expr": "0 == ' '", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "120 == ' +1.2e2 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "120.0 == ' 1.2E2 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "120 == ' 1.2e+2 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "-120 == ' -1.2E+2 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0.012 == ' 1.2e-2 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0.012 == ' 1.2E-2 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "255.0 == ' 0xff '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "8.0 == ' 0o10 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "Infinity == ' Infinity '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "-Infinity == ' -Infinity '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "NaN == 'NaN'", + "result": { "kind": "Boolean", "value": false } + }, + { "expr": "1 == '0'", "result": { "kind": "Boolean", "value": false } }, + { "expr": "1 == 'a'", "result": { "kind": "Boolean", "value": false } } + ], + + "coerce_number_null": [ + { "expr": "0 == null", "result": { "kind": "Boolean", "value": true } } + ], + + "coerce_number_object-array": [ + { + "expr": "0 == test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 == test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "0 == test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 == test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_string_bool": [ + { "expr": "'' == false", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "' ' == false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 0.0 ' == false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 1.0 ' == true", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 1 ' == true", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'-1' == false", + "result": { "kind": "Boolean", "value": false } + }, + { "expr": "'2' == true", "result": { "kind": "Boolean", "value": false } } + ], + + "coerce_string_number": [ + { + "expr": "' -0.0 ' == 0", + "result": { "kind": "Boolean", "value": true } + }, + { "expr": "'' == 0", "result": { "kind": "Boolean", "value": true } }, + { "expr": "' ' == 0", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "' +1.2e2 ' == 120", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 1.2E2 ' == 120.0", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 1.2e+2 ' == 120", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' -1.2E+2 ' == -120", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 1.2e-2 ' == 0.012", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 1.2E-2 ' == 0.012", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 0xff ' == 255.0", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 0o10 ' == 8.0", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' Infinity ' == Infinity", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' -Infinity ' == -Infinity", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'NaN' == NaN", + "result": { "kind": "Boolean", "value": false } + }, + { "expr": "'0' == 1", "result": { "kind": "Boolean", "value": false } }, + { "expr": "'a' == 1", "result": { "kind": "Boolean", "value": false } } + ], + + "coerce_string_null": [ + { "expr": "'' == null", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "' ' == null", + "result": { "kind": "Boolean", "value": true } + }, + { "expr": "'0' == null", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "'false' == null", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'null' == null", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_string_object": [ + { + "expr": "'' == test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'false' == test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'0' == test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'null' == test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Object' == test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_string_array": [ + { + "expr": "'' == test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'false' == test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'0' == test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'null' == test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Array' == test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_null_bool": [ + { + "expr": "null == false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "null == true", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_null_number": [ + { "expr": "null == 0", "result": { "kind": "Boolean", "value": true } }, + { "expr": "false == 1", "result": { "kind": "Boolean", "value": false } } + ], + + "coerce_null_string": [ + { "expr": "null == ''", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "null == ' '", + "result": { "kind": "Boolean", "value": true } + }, + { "expr": "null == '0'", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "null == 'false'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null == 'null'", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_null_object-array": [ + { + "expr": "null == test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null == test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ] +} diff --git a/actions-expressions/testdata/op_gt.json b/actions-expressions/testdata/op_gt.json new file mode 100644 index 0000000..84560a7 --- /dev/null +++ b/actions-expressions/testdata/op_gt.json @@ -0,0 +1,376 @@ +{ + "bool": [ + { + "expr": "false > true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false > false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true > false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true > true", + "result": { "kind": "Boolean", "value": false } + } + ], + "number": [ + { + "expr": "1 > 2", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 > 1", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "2 > 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1.002 > 1.001", + "result": { "kind": "Boolean", "value": true } + } + ], + "string": [ + { + "expr": "'def' > 'abc'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'a' > 'b'", + "result": { "kind": "Boolean", "value": false } + } + ], + + "array": [ + { + "expr": "test > test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "object": [ + { + "expr": "test > test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_bool_number": [ + { + "expr": "false > 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false > -11", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true > 1", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true > 0", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "false > NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true > NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false > Infinity", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true > Infinity", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false > -Infinity", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true > -Infinity", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_bool_string": [ + { + "expr": "false > '0'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false > '-1'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true > '1'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true > '0'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_bool_null": [ + { + "expr": "false > null", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true > null", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_bool_object": [ + { + "expr": "false > test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true > test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_bool_array": [ + { + "expr": "false > test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true > test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_number_bool": [ + { + "expr": "1 > false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0 > false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "2 > true", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 > true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "NaN > false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "NaN > true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "Infinity > false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "Infinity > true", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "-Infinity > false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "-Infinity > true", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_number_string": [ + { + "expr": "0 > ' 0 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "0 > ' -1 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 > ' 1 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 > ' 0 '", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_number_null": [ + { + "expr": "1 > null", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0 > null", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_number_object": [ + { + "expr": "0 > test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 > test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_number_array": [ + { + "expr": "0 > test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 > test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_string_bool": [ + { + "expr": "'0' > false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'1' > false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'1' > true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'2' > true", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_string_number": [ + { + "expr": "' 0 ' > -1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 0 ' > 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 1 ' > 0", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 1 ' > 1", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_string_null": [ + { + "expr": "'' > null", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'1' > null", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_string_object": [ + { + "expr": "'' > test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_string_array": [ + { + "expr": "'' > test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_null_bool": [ + { + "expr": "null > false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null > true", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_null_number": [ + { + "expr": "null > 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null > -1", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_null_string": [ + { + "expr": "null > ' 0 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null > ' -1 '", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_null_object": [ + { + "expr": "null > test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_null_array": [ + { + "expr": "null > test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ] +} \ No newline at end of file diff --git a/actions-expressions/testdata/op_gte.json b/actions-expressions/testdata/op_gte.json new file mode 100644 index 0000000..a3083d7 --- /dev/null +++ b/actions-expressions/testdata/op_gte.json @@ -0,0 +1,20 @@ +{ + "bool": [ + { + "expr": "false >= true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false >= false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true >= false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true >= true", + "result": { "kind": "Boolean", "value": true } + } + ] +} \ No newline at end of file diff --git a/actions-expressions/testdata/op_idx.json b/actions-expressions/testdata/op_idx.json new file mode 100644 index 0000000..c987f15 --- /dev/null +++ b/actions-expressions/testdata/op_idx.json @@ -0,0 +1,354 @@ +{ + "array-index-coercion": [ + { + "expr": "foo[null]", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "String", "value": "zero" } + }, + { + "expr": "foo[false]", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "String", "value": "zero" } + }, + { + "expr": "foo[true]", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "String", "value": "one" } + }, + { + "expr": "foo[' 0.0 ']", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "String", "value": "zero" } + }, + { + "expr": "foo[' 0x02 ']", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "String", "value": "two" } + }, + { + "expr": "foo[' asdf ']", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo[fromjson('[]')]", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo[fromjson('{}')]", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "Null", "value": null } + } + ], + "array-index-out-of-range": [ + { + "expr": "foo[-1]", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo[3]", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo[-Infinity]", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo[Infinity]", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo[NaN]", + "contexts": { + "foo": [ + "zero", + "one", + "two" + ] + }, + "result": { "kind": "Null", "value": null } + } + ], + "object-case-insensitive": [ + { + "expr": "foo['bAr']", + "contexts": { + "foo": { + "BaR": "baz" + } + }, + "result": { "kind": "String", "value": "baz" } + } + ], + "object-index-coercion": [ + { + "expr": "foo[5]", + "contexts": { + "foo": { + "5": "it's 5" + } + }, + "result": { "kind": "String", "value": "it's 5" } + }, + { + "expr": "foo[.5]", + "contexts": { + "foo": { + "0.5": "it's 0.5" + } + }, + "result": { "kind": "String", "value": "it's 0.5" } + }, + { + "expr": "foo[-.5]", + "contexts": { + "foo": { + "-0.5": "it's -0.5" + } + }, + "result": { "kind": "String", "value": "it's -0.5" } + }, + { + "expr": "foo[0x0f]", + "contexts": { + "foo": { + "15": "it's 15" + } + }, + "result": { "kind": "String", "value": "it's 15" } + }, + { + "expr": "foo[NaN]", + "contexts": { + "foo": { + "NaN": "it's NaN" + } + }, + "result": { "kind": "String", "value": "it's NaN" } + }, + { + "expr": "foo[Infinity]", + "contexts": { + "foo": { + "Infinity": "it's Infinity" + } + }, + "result": { "kind": "String", "value": "it's Infinity" } + }, + { + "expr": "foo[-Infinity]", + "contexts": { + "foo": { + "-Infinity": "it's -Infinity" + } + }, + "result": { "kind": "String", "value": "it's -Infinity" } + }, + { + "expr": "foo[null]", + "contexts": { + "foo": { + "": "it's null" + } + }, + "result": { "kind": "String", "value": "it's null" } + }, + { + "expr": "foo[true]", + "contexts": { + "foo": { + "true": "it's true" + } + }, + "result": { "kind": "String", "value": "it's true" } + }, + { + "expr": "foo[false]", + "contexts": { + "foo": { + "false": "it's false" + } + }, + "result": { "kind": "String", "value": "it's false" } + }, + { + "expr": "foo[fromJson('{}')]", + "contexts": { + "foo": { + "Object": "It's Object" + } + }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo[format('{0}', fromJson('{}'))]", + "contexts": { + "foo": { + "Object": "It's Object" + } + }, + "result": { "kind": "String", "value": "It's Object" } + }, + { + "expr": "foo[fromJson('[]')]", + "contexts": { + "foo": { + "Array": "It's Array" + } + }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo[format('{0}', fromJson('[]'))]", + "contexts": { + "foo": { + "Array": "It's Array" + } + }, + "result": { "kind": "String", "value": "It's Array" } + } + ], + "object-index-out-of-range": [ + { + "expr": "foo['']", + "contexts": { + "foo": { + "bar": "baz" + } + }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo['asdf']", + "contexts": { + "foo": { + "bar": "baz" + } + }, + "result": { "kind": "Null", "value": null } + } + ], + "index-following-function": [ + { + "expr": "fromJson('[\"one\", \"two\"]')[1]", + "result": { "kind": "String", "value": "two" } + } + ], + "index-following-group": [ + { + "expr": "(fromJson('[\"one\", \"two\"]'))[1]", + "result": { "kind": "String", "value": "two" } + } + ], + "index-star": [ + { + "expr": "foo[*]", + "contexts": { + "foo": { + "one": "one val", + "two": "two val", + "*": "star val" + } + }, + "result": { "kind": "Array", "value": ["one val", "two val", "star val"] } + }, + { + "expr": "foo['*']", + "contexts": { + "foo": { + "one": "one val", + "two": "two val", + "*": "star val" + } + }, + "result": { "kind": "String", "value": "star val" } + }, + { + "expr": "foo[*]", + "contexts": { + "foo": [ + "one", + "two" + ] + }, + "result": { "kind": "Array", "value": ["one", "two"] } + } + ] +} diff --git a/actions-expressions/testdata/op_idx_star.json b/actions-expressions/testdata/op_idx_star.json new file mode 100644 index 0000000..63aa33e --- /dev/null +++ b/actions-expressions/testdata/op_idx_star.json @@ -0,0 +1,556 @@ +{ + "filtered-array-from-literal": [ + { + "expr": "foo.*", + "contexts": { + "foo": null + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo.*", + "contexts": { + "foo": false + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo.*", + "contexts": { + "foo": true + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo.*", + "contexts": { + "foo": 123 + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo.*", + "contexts": { + "foo": "abc" + }, + "result": { "kind": "Array", "value": [] } + } + ], + "filtered-array-from-array": [ + { + "expr": "foo.*", + "contexts": { + "foo": [ + "zero", + "one" + ] + }, + "result": { "kind": "Array", "value": ["zero", "one"] } + } + ], + "filtered-array-from-object": [ + { + "expr": "foo.*", + "contexts": { + "foo": { + "one": "one val", + "two": "two val" + } + }, + "result": { "kind": "Array", "value": ["one val", "two val"] } + } + ], + "nested-object": [ + { + "expr": "foo.*.one", + "contexts": { + "foo": [ + { + "one": "obj_1_prop_1", + "two": "obj_1_prop_2" + }, + { + "one": "obj_2_prop_1", + "two": "obj_2_prop_2" + } + ] + }, + "result": { "kind": "Array", "value": ["obj_1_prop_1", "obj_2_prop_1"] } + }, + { + "expr": "foo[*]['']", + "contexts": { + "foo": [ + { + "": "empty string" + } + ] + }, + "result": { "kind": "Array", "value": ["empty string"] } + } + ], + "nested-object-property-case-insensitive": [ + { + "expr": "foo.*.thePROPERTY", + "contexts": { + "foo": [ + { + "THEproperty": "the property" + } + ] + }, + "result": { "kind": "Array", "value": ["the property"] } + } + ], + "nested-object-property-not-found": [ + { + "expr": "foo.*.one", + "contexts": { + "foo": [ + { + "one": "obj_1_prop_1", + "two": "obj_1_prop_2" + }, + { + "zero": "obj_2_prop_0", + "three": "obj_2_prop_3" + }, + { + "one": "obj_3_prop_1", + "two": "obj_3_prop_2" + } + ] + }, + "result": { "kind": "Array", "value": ["obj_1_prop_1", "obj_3_prop_1"] } + } + ], + "nested-object-property-coercion": [ + { + "expr": "foo[*][null]", + "contexts": { + "foo": [ + { + "": "empty string" + } + ] + }, + "result": { "kind": "Array", "value": ["empty string"] } + }, + { + "expr": "foo[*][false]", + "contexts": { + "foo": [ + { + "false": "It's false" + } + ] + }, + "result": { "kind": "Array", "value": ["It's false"] } + }, + { + "expr": "foo[*][true]", + "contexts": { + "foo": [ + { + "true": "It's true" + } + ] + }, + "result": { "kind": "Array", "value": ["It's true"] } + }, + { + "expr": "foo[*][0x0f]", + "contexts": { + "foo": [ + { + "15": "fifteen" + } + ] + }, + "result": { "kind": "Array", "value": ["fifteen"] } + }, + { + "expr": "foo[*][NaN]", + "contexts": { + "foo": [ + { + "NaN": "It's NaN" + } + ] + }, + "result": { "kind": "Array", "value": ["It's NaN"] } + }, + { + "expr": "foo[*][Infinity]", + "contexts": { + "foo": [ + { + "Infinity": "It's Infinity" + } + ] + }, + "result": { "kind": "Array", "value": ["It's Infinity"] } + }, + { + "expr": "foo[*][-Infinity]", + "contexts": { + "foo": [ + { + "-Infinity": "It's -Infinity" + } + ] + }, + "result": { "kind": "Array", "value": ["It's -Infinity"] } + }, + { + "expr": "foo[*][fromjson('[]')]", + "contexts": { + "foo": [ + { + "": "empty string", + "Array": "It's array" + } + ] + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo[*][fromjson('{}')]", + "contexts": { + "foo": [ + { + "": "empty string", + "Object": "It's object" + } + ] + }, + "result": { "kind": "Array", "value": [] } + } + ], + "nested-array": [ + { + "expr": "foo[*][1]", + "contexts": { + "foo": [ + [ + "ary_0_idx_0", + "ary_0_idx_1", + "ary_0_idx_2" + ], + [ + "ary_1_idx_0", + "ary_1_idx_1", + "ary_1_idx_2" + ] + ] + }, + "result": { "kind": "Array", "value": ["ary_0_idx_1", "ary_1_idx_1"] } + } + ], + "nested-array-index-not-found": [ + { + "expr": "foo[*][1]", + "contexts": { + "foo": [ + [ + "ary_0_idx_0", + "ary_0_idx_1", + "ary_0_idx_2" + ], + [ + "ary_1_idx_0" + ], + [ + "ary_2_idx_0", + "ary_2_idx_1", + "ary_2_idx_2" + ] + ] + }, + "result": { "kind": "Array", "value": ["ary_0_idx_1", "ary_2_idx_1"] } + }, + { + "expr": "foo[*][-1]", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo[*][NaN]", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo[*][Infinity]", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo[*][Infinity]", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": [] } + } + ], + "nested-array-index-coercion": [ + { + "expr": "foo[*][null]", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": ["zero"] } + }, + { + "expr": "foo[*][false]", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": ["zero"] } + }, + { + "expr": "foo[*][true]", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": ["one"] } + }, + { + "expr": "foo[*]['']", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": ["zero"] } + }, + { + "expr": "foo[*]['abc']", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo[*][' 0x01 ']", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": ["one"] } + }, + { + "expr": "foo[*][fromjson('[]')]", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo[*][fromjson('{}')]", + "contexts": { + "foo": [ + [ + "zero", + "one", + "two" + ] + ] + }, + "result": { "kind": "Array", "value": [] } + } + ], + "double-star": [ + { + "expr": "foo.*.*", + "contexts": { + "foo": [ + null, + false, + true, + 123, + "hello", + [ + "zero", + "one", + "two" + ], + { + "p1": "v1", + "p2": "v2", + "p3": "v3" + } + ] + }, + "result": { "kind": "Array", "value": ["zero", "one", "two", "v1", "v2", "v3"] } + } + ], + "double-property": [ + { + "expr": "foo.*.p.nestedp", + "contexts": { + "foo": [ + { + "p": { + "nestedp": "val 1" + } + }, + { + "p": { + "nestedp": "val 2" + } + } + ] + }, + "result": { "kind": "Array", "value": ["val 1", "val 2"] } + } + ], + "double-index": [ + { + "expr": "foo[*][1][2]", + "contexts": { + "foo": [ + [ + [ + "0_0_0", + "0_0_1", + "0_0_2", + "0_0_3" + ], + [ + "0_1_0", + "0_1_1", + "0_1_2", + "0_1_3" + ], + [ + "0_2_0", + "0_2_1", + "0_2_2", + "0_2_3" + ] + ], + [ + [ + "1_0_0", + "1_0_1", + "1_0_2", + "1_0_3" + ], + [ + "1_1_0", + "1_1_1", + "1_1_2", + "1_1_3" + ], + [ + "1_2_0", + "1_2_1", + "1_2_2", + "1_2_3" + ] + ] + ] + }, + "result": { "kind": "Array", "value": ["0_1_2", "1_1_2"] } + } + ], + "not-found-always-returns-empty-array": [ + { + "expr": "foo[*][1]", + "contexts": { + "foo": [] + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo[*][1][2]", + "contexts": { + "foo": [] + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo.*.bar", + "contexts": { + "foo": {} + }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo.*.bar.baz", + "contexts": { + "foo": [] + }, + "result": { "kind": "Array", "value": [] } + } + ] +} diff --git a/actions-expressions/testdata/op_lt.json b/actions-expressions/testdata/op_lt.json new file mode 100644 index 0000000..f214a7b --- /dev/null +++ b/actions-expressions/testdata/op_lt.json @@ -0,0 +1,378 @@ +{ + "bool": [ + { + "expr": "false < true", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "false < false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true < false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true < true", + "result": { "kind": "Boolean", "value": false } + } + ], + + "number": [ + { + "expr": "1 < 2", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 < 1", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "2 < 1", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1.001 < 1.002", + "result": { "kind": "Boolean", "value": true } + } + ], + + "string": [ + { + "expr": "'abc' < 'def'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'b' < 'a'", + "result": { "kind": "Boolean", "value": false } + } + ], + + "array": [ + { + "expr": "test < test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "object": [ + { + "expr": "test < test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_bool_number": [ + { + "expr": "false < 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false < 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true < 1", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true < 2", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "false < NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true < NaN", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false < Infinity", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true < Infinity", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "false < -Infinity", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true < -Infinity", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_bool_string": [ + { + "expr": "false < '0'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false < '1'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true < '1'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true < '2'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_bool_null": [ + { + "expr": "false < null", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true < null", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_bool_object": [ + { + "expr": "false < test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true < test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_bool_array": [ + { + "expr": "false < test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true < test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_number_bool": [ + { + "expr": "-1 < false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0 < false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "0 < true", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 < true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "NaN < false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "NaN < true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "Infinity < false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "Infinity < true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "-Infinity < false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "-Infinity < true", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_number_string": [ + { + "expr": "0 < ' 0 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "0 < ' 1 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 < ' 1 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 < ' 2 '", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_number_null": [ + { + "expr": "-1 < null", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0 < null", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_number_object": [ + { + "expr": "0 < test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "-1 < test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_number_array": [ + { + "expr": "0 < test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "-1 < test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_string_bool": [ + { + "expr": "'0' < false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'-1' < false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'1' < true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'0' < true", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_string_number": [ + { + "expr": "' 0 ' < 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 0 ' < 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 1 ' < 2", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 1 ' < 1", + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_string_null": [ + { + "expr": "'' < null", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'-1' < null", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_string_object": [ + { + "expr": "'' < test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_string_array": [ + { + "expr": "'' < test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_null_bool": [ + { + "expr": "null < false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null < true", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_null_number": [ + { + "expr": "null < 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null < 1", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_null_string": [ + { + "expr": "null < ' 0 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null < ' 1 '", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_null_object": [ + { + "expr": "null < test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": false } + } + ], + + "coerce_null_array": [ + { + "expr": "null < test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": false } + } + ] +} \ No newline at end of file diff --git a/actions-expressions/testdata/op_lte.json b/actions-expressions/testdata/op_lte.json new file mode 100644 index 0000000..40702e9 --- /dev/null +++ b/actions-expressions/testdata/op_lte.json @@ -0,0 +1,20 @@ +{ + "bool": [ + { + "expr": "false <= true", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "false <= false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true <= false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true <= true", + "result": { "kind": "Boolean", "value": true } + } + ] +} \ No newline at end of file diff --git a/actions-expressions/testdata/op_ne.json b/actions-expressions/testdata/op_ne.json new file mode 100644 index 0000000..19aa700 --- /dev/null +++ b/actions-expressions/testdata/op_ne.json @@ -0,0 +1,597 @@ +{ + "boolean": [ + { + "expr": "true != true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false != false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true != true", + "result": { "kind": "Boolean", "value": false } + } + ], + + "number": [ + { + "expr": "0 != -0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "2 != 2", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "120 != 1.2e2", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 != 2", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1.001 != 1.002", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "NaN != NaN", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0 != NaN", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 != NaN", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "Infinity != Infinity", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "-Infinity != Infinity", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0 != Infinity", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 != Infinity", + "result": { "kind": "Boolean", "value": true } + } + ], + + "string": [ + { + "expr": "'a' != 'a'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'a' != 'b'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "null": [ + { "expr": "null != null", "result": { "kind": "Boolean", "value": false } } + ], + + "object": [ + { + "expr": "object1 != object1", + "contexts": { "object1": {} }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "object1 != object2", + "contexts": { "object1": {}, "object2": {} }, + "result": { "kind": "Boolean", "value": true } + } + ], + + "array": [ + { + "expr": "array1 != array1", + "contexts": { "array1": [] }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "array1 != array2", + "contexts": { "array1": [], "array2": [] }, + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_bool_number": [ + { + "expr": "false != 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false != 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true != 1", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false != NaN", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true != 2", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_bool_string": [ + { + "expr": "false != ''", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false != ' '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false != ' 0.0 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false != ' 1.0 '", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true != ' 1.0 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true != ' 1 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "false != '-1'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true != '2'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_bool_null": [ + { + "expr": "false != null", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "true != null", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_bool_object-array": [ + { + "expr": "false != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "false != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "true != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_number_bool": [ + { + "expr": "0 != false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 != false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 != true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "2 != true", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "NaN != false", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_number_string": [ + { + "expr": "0 != ' -0.0 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "0 != ''", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "0 != ' '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "120 != ' +1.2e2 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "120.0 != ' 1.2E2 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "120 != ' 1.2e+2 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "-120 != ' -1.2E+2 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "0.012 != ' 1.2e-2 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "0.012 != ' 1.2E-2 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "255.0 != ' 0xff '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "8.0 != ' 0o10 '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "Infinity != ' Infinity '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "-Infinity != ' -Infinity '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "NaN != 'NaN'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 != '0'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 != '1'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 != 'a'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0 != 'a'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_number_null": [ + { + "expr": "0 != null", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 != null", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_number_object-array": [ + { + "expr": "0 != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0 != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "1 != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_string_bool": [ + { + "expr": "'' != false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' ' != false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 0.0 ' != false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 1.0 ' != false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "' 1.0 ' != true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 1 ' != true", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'-1' != true", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'2' != true", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_string_number": [ + { + "expr": "' -0.0 ' != 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'' != 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' ' != 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' +1.2e2 ' != 120", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 1.2E2 ' != 120.0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 1.2e+2 ' != 120", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' -1.2E+2 ' != -120", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 1.2e-2 ' != 0.012", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 1.2E-2 ' != 0.012", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 0xff ' != 255.0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' 0o10 ' != 8.0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' Infinity ' != Infinity", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' -Infinity ' != -Infinity", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'NaN' != NaN", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'0' != 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'1' != 1", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'a' != 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'a' != 0", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_string_null": [ + { + "expr": "'' != null", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "' ' != null", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'0' != null", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'1' != null", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'false' != null", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'null' != null", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_string_object": [ + { + "expr": "'' != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'false' != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'0' != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'1' != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'null' != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Object' != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_string_array": [ + { + "expr": "'' != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'false' != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'0' != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'0' != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'null' != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Array' != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_null_bool": [ + { + "expr": "null != false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null != true", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_null_number": [ + { + "expr": "null != 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null != 1", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_null_string": [ + { + "expr": "null != ''", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null != ' '", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null != '0'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "null != '1'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "null != 'false'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "null != 'null'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "coerce_null_object-array": [ + { + "expr": "null != test", + "contexts": { "test": {} }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "null != test", + "contexts": { "test": [] }, + "result": { "kind": "Boolean", "value": true } + } + ] +} diff --git a/actions-expressions/testdata/op_not.json b/actions-expressions/testdata/op_not.json new file mode 100644 index 0000000..b3085ad --- /dev/null +++ b/actions-expressions/testdata/op_not.json @@ -0,0 +1,28 @@ +{ + "not": [ + { "expr": "!!true", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!false", "result": { "kind": "Boolean", "value": false } }, + { "expr": "!!1", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!.5", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!0.5", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!2", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!-1", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!-.5", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!-2", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!-Infinity", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!Infinity", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!NaN", "result": { "kind": "Boolean", "value": false } }, + { "expr": "!!0", "result": { "kind": "Boolean", "value": false } }, + { "expr": "!!0.0", "result": { "kind": "Boolean", "value": false } }, + { "expr": "!!-0", "result": { "kind": "Boolean", "value": false } }, + { "expr": "!!-0.0", "result": { "kind": "Boolean", "value": false } }, + { "expr": "!!'a'", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!'false'", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!'0'", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!' '", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!''", "result": { "kind": "Boolean", "value": false } }, + { "expr": "!!fromJson('[]')", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!fromJson('{}')", "result": { "kind": "Boolean", "value": true } }, + { "expr": "!!null", "result": { "kind": "Boolean", "value": false } } + ] +} diff --git a/actions-expressions/testdata/op_or.json b/actions-expressions/testdata/op_or.json new file mode 100644 index 0000000..b1b1209 --- /dev/null +++ b/actions-expressions/testdata/op_or.json @@ -0,0 +1,31 @@ +{ + "or": [ + { + "expr": "true || true || true", + "result": { "kind": "Boolean", "value": true } + }, + { "expr": "true || true", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "true || true || false", + "result": { "kind": "Boolean", "value": true } + }, + { "expr": "true || false", "result": { "kind": "Boolean", "value": true } }, + { "expr": "false || true", "result": { "kind": "Boolean", "value": true } }, + { + "expr": "false || false", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "0 || 0 || 2 || 3", + "result": { "kind": "Number", "value": 2.0 } + }, + { "expr": "false || 0", "result": { "kind": "Number", "value": 0.0 } }, + { + "expr": "false || '' || 'a' || 'b'", + "result": { "kind": "String", "value": "a" } + }, + { "expr": "false || ''", "result": { "kind": "String", "value": "" } }, + { "expr": "null || true", "result": { "kind": "Boolean", "value": true } }, + { "expr": "false || null", "result": { "kind": "Null", "value": null } } + ] +} diff --git a/actions-expressions/testdata/operators_case_insensitive.json b/actions-expressions/testdata/operators_case_insensitive.json new file mode 100644 index 0000000..164c178 --- /dev/null +++ b/actions-expressions/testdata/operators_case_insensitive.json @@ -0,0 +1,376 @@ +{ + "lower-vs-upper": [ + { + "expr": "'abcdefghijklmnopqrstuvwxyz' == 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'abcdefghijklmnopqrstuvwxyz' != 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'abcdefghijklmnopqrstuvwxyz' < 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'abcdefghijklmnopqrstuvwxyz' <= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'abcdefghijklmnopqrstuvwxyz' > 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'abcdefghijklmnopqrstuvwxyz' >= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "upper-vs-lower": [ + { + "expr": "'ABCDEFGHIJKLMNOPQRSTUVWXYZ' == 'abcdefghijklmnopqrstuvwxyz'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ABCDEFGHIJKLMNOPQRSTUVWXYZ' != 'abcdefghijklmnopqrstuvwxyz'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ABCDEFGHIJKLMNOPQRSTUVWXYZ' < 'abcdefghijklmnopqrstuvwxyz'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ABCDEFGHIJKLMNOPQRSTUVWXYZ' <= 'abcdefghijklmnopqrstuvwxyz'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ABCDEFGHIJKLMNOPQRSTUVWXYZ' > 'abcdefghijklmnopqrstuvwxyz'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ABCDEFGHIJKLMNOPQRSTUVWXYZ' >= 'abcdefghijklmnopqrstuvwxyz'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "a-z-equivalent-to-A-Z-wrt-chars-between-Z-and-a": [ + { + "expr": "'A' < '['", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'A' <= '['", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'A' > '['", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'A' >= '['", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'a' < '['", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'a' <= '['", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'a' > '['", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'a' >= '['", + "result": { "kind": "Boolean", "value": false } + } + ], + + "case-insensitive-ς-(final-lowercase-sigma)-Σ-(capital-sigma)-σ-(non-final-sigma)": [ + { + "expr": "'ς' == 'Σ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ς' != 'Σ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ς' < 'Σ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ς' <= 'Σ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ς' > 'Σ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ς' >= 'Σ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ς' == 'σ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ς' != 'σ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ς' < 'σ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ς' <= 'σ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ς' > 'σ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ς' >= 'σ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Σ' == 'σ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Σ' != 'σ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Σ' < 'σ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Σ' <= 'σ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Σ' > 'σ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Σ' >= 'σ'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-insensitive-ü-Ü": [ + { + "expr": "'ü' == 'Ü'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ü' != 'Ü'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ü' < 'Ü'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ü' <= 'Ü'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ü' > 'Ü'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ü' >= 'Ü'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Ü' == 'ü'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Ü' != 'ü'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Ü' < 'ü'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Ü' <= 'ü'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Ü' > 'ü'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Ü' >= 'ü'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-insensitive-ç-Ç": [ + { + "expr": "'ç' == 'Ç'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ç' != 'Ç'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ç' < 'Ç'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ç' <= 'Ç'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ç' > 'Ç'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ç' >= 'Ç'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Ç' == 'ç'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Ç' != 'ç'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Ç' < 'ç'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Ç' <= 'ç'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'Ç' > 'ç'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'Ç' >= 'ç'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-sensitive-i-İ": [ + { + "expr": "'i' == 'İ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'i' != 'İ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'i' < 'İ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'i' <= 'İ'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'i' > 'İ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'i' >= 'İ'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'İ' == 'i'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'İ' != 'i'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'İ' < 'i'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'İ' <= 'i'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'İ' > 'i'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'İ' >= 'i'", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-sensitive-ı-I": [ + { + "expr": "'ı' == 'I'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ı' != 'I'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ı' < 'I'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ı' <= 'I'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'ı' > 'I'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'ı' >= 'I'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'I' == 'ı'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'I' != 'ı'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'I' < 'ı'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'I' <= 'ı'", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "'I' > 'ı'", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'I' >= 'ı'", + "result": { "kind": "Boolean", "value": false } + } + ], + + "cyrillic-letters": [ + { + "expr": "'АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЬЭЮЯ' == 'абвгдежзийклмнопрстуфхцчшщьэюя'", + "result": { "kind": "Boolean", "value": true } + } + ] +} diff --git a/actions-expressions/testdata/operators_precedence.json b/actions-expressions/testdata/operators_precedence.json new file mode 100644 index 0000000..681a004 --- /dev/null +++ b/actions-expressions/testdata/operators_precedence.json @@ -0,0 +1,171 @@ +{ + "operator_precedence_._and_._evaluate_left_to_right": [ + { + "expr": "foo.bar.baz", + "contexts": { "foo": { "bar": { "baz": 2 } } }, + "result": { "kind": "Number", "value": 2.0 } + } + ], + "operator_precedence_._is_higher_than_!": [ + { + "expr": "!foo.bar", + "contexts": { "foo": { "bar": true } }, + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "(!foo).bar", + "contexts": { "foo": { "bar": true } }, + "result": { "kind": "Null", "value": null } + } + ], + "operator_precedence_._is_higher_than_>": [ + { + "expr": "3 > foo.bar", + "contexts": { "foo": { "bar": 2 } }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "foo.bar > 2", + "contexts": { "foo": { "bar": 3 } }, + "result": { "kind": "Boolean", "value": true } + } + ], + "operator_precedence_[]_is_higher_than_>": [ + { + "expr": "3 > foo['bar']", + "contexts": { "foo": { "bar": 2 } }, + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "foo['bar'] > 2", + "contexts": { "foo": { "bar": 3 } }, + "result": { "kind": "Boolean", "value": true } + } + ], + "operator_precedence_!_is_higher_than_<": [ + { + "expr": "!2 < 3", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "!(2 < 3)", + "result": { "kind": "Boolean", "value": false } + } + ], + "operator_precedence_<_and_<_evaluate_left_to_right": [ + { + "expr": "3 < 2 < 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "3 < (2 < 1)", + "result": { "kind": "Boolean", "value": false } + } + ], + "operator_precedence_<_and_<=_evaluate_left_to_right": [ + { + "expr": "3 < 2 <= 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "3 < (2 <= 1)", + "result": { "kind": "Boolean", "value": false } + } + ], + "operator_precedence_<=_and_<_evaluate_left_to_right": [ + { + "expr": "3 <= 2 < 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "3 <= (2 < 1)", + "result": { "kind": "Boolean", "value": false } + } + ], + "operator_precedence_>_and_<_evaluate_left_to_right": [ + { + "expr": "0 > 0 < 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "0 > (0 < 1)", + "result": { "kind": "Boolean", "value": false } + } + ], + "operator_precedence_>_and_>_evaluate_left_to_right": [ + { + "expr": "2 > 2 > 0", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "2 > (2 > 0)", + "result": { "kind": "Boolean", "value": true } + } + ], + "operator_precedence_<=_is_higher_than_==": [ + { + "expr": "2 <= 3 == true", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "2 <= (3 == true)", + "result": { "kind": "Boolean", "value": false } + } + ], + "operator_precedence_==_and_==_evaluate_left_to_right": [ + { + "expr": "2 == 2 == 1", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "2 == (2 == 1)", + "result": { "kind": "Boolean", "value": false } + } + ], + "operator_precedence_==_and_!=_evaluate_left_to_right": [ + { + "expr": "2 == 2 != 0", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "2 == (2 != 0)", + "result": { "kind": "Boolean", "value": false } + } + ], + "operator_precedence_==_is_higher_than_&&": [ + { + "expr": "1 == 1 && 2", + "result": { "kind": "Number", "value": 2.0 } + }, + { + "expr": "1 == (1 && 2)", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "'a' == 'a' && 'b'", + "result": { "kind": "String", "value": "b" } + }, + { + "expr": "'a' == ('a' && 'b')", + "result": { "kind": "Boolean", "value": false } + } + ], + "operator_precedence_&&_is_higher_than_||": [ + { + "expr": "false && 0 || null", + "result": { "kind": "Null", "value": null } + }, + { + "expr": "false && (0 || null)", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "1 || 2 && 3", + "result": { "kind": "Number", "value": 1.0 } + }, + { + "expr": "(1 || 2) && 3", + "result": { "kind": "Number", "value": 3.0 } + } + ] +} diff --git a/actions-expressions/testdata/startsWith.json b/actions-expressions/testdata/startsWith.json new file mode 100644 index 0000000..d435af2 --- /dev/null +++ b/actions-expressions/testdata/startsWith.json @@ -0,0 +1,143 @@ +{ + "basics": [ + { + "expr": "startsWith('abcdef', 'abc')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('abcdef', 'bc')", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "startsWith('abcdef', '')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('1234', 12)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('true', true)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('false', false)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('asdf', null)", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('1234', 23)", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "startsWith('true', false)", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "startsWith('true', false, true)", + "err": { + "kind": "parsing", + "value": "Too many parameters supplied: " + } + }, + { + "expr": "startsWith('true')", + "err": { + "kind": "parsing", + "value": "Too few parameters supplied: " + } + } + ], + + "case-insensitive-a-thru-z": [ + { + "expr": "startsWith('ABCDEFGHIJKLMNOPQRSTUVWXYZ_asdf', 'abcdefghijklmnopqrstuvwxyz')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('abcdefghijklmnopqrstuvwxyz_asdf', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-insensitive-ς-(final-lowercase-sigma)-Σ-(capital-sigma)-σ-(non-final-sigma)": [ + { + "expr": "startsWith('ς_asdf', 'Σ')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('ς_asdf', 'σ')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('Σ_asdf', 'ς')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('Σ_asdf', 'σ')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('σ_asdf', 'ς')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('σ_asdf', 'Σ')", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-insensitive-ü-Ü": [ + { + "expr": "startsWith('ü_asdf', 'Ü')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('Ü_asdf', 'ü')", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-insensitive-ç-Ç": [ + { + "expr": "startsWith('ç_asdf', 'Ç')", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "startsWith('Ç_asdf', 'ç')", + "result": { "kind": "Boolean", "value": true } + } + ], + + "case-sensitive-i-İ": [ + { + "expr": "startsWith('i_asdf', 'İ')", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "startsWith('İ_asdf', 'i')", + "result": { "kind": "Boolean", "value": false } + } + ], + + "case-sensitive-ı-I": [ + { + "expr": "startsWith('ı_asdf', 'I')", + "result": { "kind": "Boolean", "value": false } + }, + { + "expr": "startsWith('I_asdf', 'ı')", + "result": { "kind": "Boolean", "value": false } + } + ], + + "cyrillic-letters": [ + { + "expr": "startsWith('АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЬЭЮЯ_asdf', 'абвгдежзийклмнопрстуфхцчшщьэюя')", + "result": { "kind": "Boolean", "value": true } + } + ] +} diff --git a/actions-expressions/testdata/syntax-errors.json b/actions-expressions/testdata/syntax-errors.json new file mode 100644 index 0000000..1066f5d --- /dev/null +++ b/actions-expressions/testdata/syntax-errors.json @@ -0,0 +1,605 @@ +{ + "parsing-errors": [ + { + "expr": "1 <", + "err": { + "kind": "parsing", + "value": "Unexpected end of expression: '<'. Located at position 3 within expression: 1 <" + } + }, + { + "expr": "1 < )", + "err": { + "kind": "parsing", + "value": "Unexpected symbol: ')'. Located at position 5 within expression: 1 < )" + } + }, + { + "expr": "> 1", + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '>'. Located at position 1 within expression: > 1" + } + }, + { + "expr": "github.()", + "contexts": { "github": { "sha": "abc123" } }, + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '('. Located at position 8 within expression: github.()" + } + }, + { + "expr": "endswith(1, 2 a)", + "err": { + "kind": "parsing", + "value": "Unexpected symbol: 'a'. Located at position 15 within expression: endswith(1, 2 a)" + } + }, + { + "expr": "(1,", + "err": { + "kind": "parsing", + "value": "Unexpected symbol: ','. Located at position 3 within expression: (1," + } + }, + { + "expr": "1 2", + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '2'. Located at position 3 within expression: 1 2" + } + }, + { + "expr": "(1)2", + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '2'. Located at position 4 within expression: (1)2" + } + }, + { + "expr": "tojson(1)2", + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '2'. Located at position 10 within expression: tojson(1)2" + } + }, + { + "expr": "foo 2", + "contexts": { "foo": "bar" }, + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '2'. Located at position 5 within expression: foo 2" + } + }, + { + "expr": "foo.bar 2", + "contexts": { "foo": "bar" }, + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '2'. Located at position 9 within expression: foo.bar 2" + } + }, + { + "expr": "foo['bar']2", + "contexts": { "foo": "bar" }, + "err": { + "kind": "parsing", + "value": "Unexpected symbol: '2'. Located at position 11 within expression: foo['bar']2" + } + }, + { + "expr": "nonExistentFunction()", + "err": { + "kind": "parsing", + "value": "Unrecognized function: 'nonExistentFunction'. Located at position 1" + } + }, + { + "expr": "false && nonExistentFunction()", + "err": { + "kind": "parsing", + "value": "Unrecognized function: 'nonExistentFunction'. Located at position 1" + } + }, + { + "expr": "1 = < 2", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '='. Located at position 3 within expression: 1 = < 2" + } + }, + { + "expr": "1 & < 2", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '&'. Located at position 3 within expression: 1 & < 2" + } + }, + { + "expr": "1 | < 2", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '|'. Located at position 3 within expression: 1 | < 2" + } + }, + { + "expr": "1 =2 =3", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '=2'. Located at position 3 within expression: 1 =2 =3" + } + }, + { + "expr": "1 &2 &3", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '&2'. Located at position 3 within expression: 1 &2 &3" + } + }, + { + "expr": "1 |2 |3", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '|2'. Located at position 3 within expression: 1 |2 |3" + } + }, + { + "expr": "1 @abc 2", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '@abc'. Located at position 3 within expression: 1 @abc 2" + } + }, + { + "expr": "'foo", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: ''foo'. Located at position 1 within expression: 'foo" + } + }, + { + "expr": "fromjson(a,b ", + "err": { + "kind": "parsing", + "value": "Unrecognized named-value: 'a'. Located at position 10 within expression: fromjson(a,b " + } + }, + { + "expr": " true ||( false && false", + "err": { + "kind": "parsing", + "value": "Unexpected end of expression: 'false'. Located at position 20 within expression: true ||( false && false" + } + }, + { + "expr": "😊 Emoji!! (unicode)", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '😊'. Located at position 1 within expression: 😊 Emoji!! (unicode)" + } + }, + { + "expr": "github.event_name == 'push'\n && !contains(env.COMMIT_MESSAGES)", + "contexts": { "github": { "event_name": "push" }}, + "err": { + "kind": "parsing", + "value": "Unrecognized named-value: 'env'. Located at position 43 within expression: github.event_name == 'push'\n && !contains(env.COMMIT_MESSAGES)" + }, + "options": { + "skip": ["typescript"] + } + }, + { + "expr": "github.event_name == 'push' \n&& true\n&& !contains(env.COMMIT_MESSAGES)", + "contexts": { "github": { "event_name": "push" }}, + "err": { + "kind": "parsing", + "value": "Unrecognized named-value: 'env'. Located at position 51 within expression: github.event_name == 'push' \n&& true\n&& !contains(env.COMMIT_MESSAGES)" + }, + "options": { + "skip": ["typescript"] + } + }, + { + "expr": "github.event_name == 'push' \n&& true\n&& true\n&& !contains(env.COMMIT_MESSAGES)", + "contexts": { "github": { "event_name": "push" }}, + "err": { + "kind": "parsing", + "value": "Unrecognized named-value: 'env'. Located at position 59 within expression: github.event_name == 'push' \n&& true\n&& true\n&& !contains(env.COMMIT_MESSAGES)" + }, + "options": { + "skip": ["typescript"] + } + }, + { + "expr": "github.event_name == 'push' \n&& true\n&& true\n&& true\n&& !contains(env.COMMIT_MESSAGES)", + "contexts": { "github": { "event_name": "push" }}, + "err": { + "kind": "parsing", + "value": "Unrecognized named-value: 'env'. Located at position 67 within expression: github.event_name == 'push' \n&& true\n&& true\n&& true\n&& !contains(env.COMMIT_MESSAGES)" + }, + "options": { + "skip": ["typescript"] + } + }, + { + "expr": "1 && ..github/workflow.md", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '..github/workflow.md'. Located at position 6 within expression: 1 && ..github/workflow.md" + } + }, + { + "expr": "..github/workflow.md", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '..github/workflow.md'. Located at position 1 within expression: ..github/workflow.md" + } + }, + { + "expr": ".github/workflow.md", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '.github/workflow.md'. Located at position 1 within expression: .github/workflow.md" + } + }, + { + "expr": ".github/workflow", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '.github/workflow'. Located at position 1 within expression: .github/workflow" + } + }, + { + "expr": "github/workflow.md", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: 'github/workflow'. Located at position 1 within expression: github/workflow.md" + } + }, + { + "expr": "..github/workflow.md && 1", + "err": { + "kind": "lexing", + "value": "Unexpected symbol: '..github/workflow.md'. Located at position 1 within expression: ..github/workflow.md && 1" + } + } + ], + "depth-errors": [ + { + "expr": "!!!!! !!!!! !!!!! !!!!! !!!!! !!!!! !!!!! !!!!! !!!!! !!!!! false", + "err": { + "kind": "parsing", + "value": "Exceeded max expression depth 50" + } + }, + { + "expr": "!!!!! !!!!! !!!!! !!!!! !!!!! !!!!! !!!!! !!!!! !!!!! !!!! false", + "result": { "kind": "Boolean", "value": true } + }, + { + "expr": "foo._1._2._3._4._5._6._7._8._9._10._11._12._13._14._15._16._17._18._19._20._21._22._23._24._25._26._27._28._29._30._31._32._33._34._35._36._37._38._39._40._41._42._43._44._45._46._47._48._49._50", + "contexts": { "foo": null }, + "err": { + "kind": "parsing", + "value": "Exceeded max expression depth 50" + } + }, + { + "expr": "foo._1._2._3._4._5._6._7._8._9._10._11._12._13._14._15._16._17._18._19._20._21._22._23._24._25._26._27._28._29._30._31._32._33._34._35._36._37._38._39._40._41._42._43._44._45._46._47._48._49", + "contexts": { "foo": null }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.*", + "contexts": { "foo": null }, + "err": { + "kind": "parsing", + "value": "Exceeded max expression depth 50" + } + }, + { + "expr": "foo .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*.* .*.*.*.*", + "contexts": { "foo": null }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "foo[1][2][3][4][5][6][7][8][9][10][11][12][13][14][15][16][17][18][19][20][21][22][23][24][25][26][27][28][29][30][31][32][33][34][35][36][37][38][39][40][41][42][43][44][45][46][47][48][49][50]", + "contexts": { "foo": null }, + "err": { + "kind": "parsing", + "value": "Exceeded max expression depth 50" + } + }, + { + "expr": "foo[1][2][3][4][5][6][7][8][9][10][11][12][13][14][15][16][17][18][19][20][21][22][23][24][25][26][27][28][29][30][31][32][33][34][35][36][37][38][39][40][41][42][43][44][45][46][47][48][49]", + "contexts": { "foo": null }, + "result": { "kind": "Null", "value": null } + }, + { + "expr": "foo [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*]", + "contexts": { "foo": null }, + "err": { + "kind": "parsing", + "value": "Exceeded max expression depth 50" + } + }, + { + "expr": "foo [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*][*] [*][*][*][*]", + "contexts": { "foo": null }, + "result": { "kind": "Array", "value": [] } + }, + { + "expr": "fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson( fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson( fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson( fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson( fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson( 1 )))))))))) )))))))))) )))))))))) )))))))))) ))))))))))", + "err": { + "kind": "parsing", + "value": "Exceeded max expression depth 50" + } + }, + { + "expr": "fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson( fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson( fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson( fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson( fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson(tojson(fromjson( '1' ))))))))) )))))))))) )))))))))) )))))))))) ))))))))))", + "result": { "kind": "Number", "value": 1 } + }, + { + "expr": "format('depth3'||format('depth5'||format('depth7'||format('depth9'||format('depth11'||format('depth13'||format('depth15'||format('depth17'||format('depth19'||format('depth21'||format('depth23'||format('depth25'||format('depth27'||format('depth29'||format('depth31'||format('depth33'||format('depth35'||format('depth37'||format('depth39'||format('depth41'||format('depth43'||format('depth45'||format('depth47'||format('depth49'||format('depth51'||'depth51')))))))))) )))))))))) )))))", + "err": { + "kind": "parsing", + "value": "Exceeded max expression depth 50" + } + }, + { + "expr": "format('depth3'||format('depth5'||format('depth7'||format('depth9'||format('depth11'||format('depth13'||format('depth15'||format('depth17'||format('depth19'||format('depth21'||format('depth23'||format('depth25'||format('depth27'||format('depth29'||format('depth31'||format('depth33'||format('depth35'||format('depth37'||format('depth39'||format('depth41'||format('depth43'||format('depth45'||format('depth47'||format('depth49'||format('depth50')))))))))) )))))))))) )))))", + "result": { "kind": "String", "value": "depth3" } + }, + { + "expr": "format('depth3'&&format('depth5'&&format('depth7'&&format('depth9'&&format('depth11'&&format('depth13'&&format('depth15'&&format('depth17'&&format('depth19'&&format('depth21'&&format('depth23'&&format('depth25'&&format('depth27'&&format('depth29'&&format('depth31'&&format('depth33'&&format('depth35'&&format('depth37'&&format('depth39'&&format('depth41'&&format('depth43'&&format('depth45'&&format('depth47'&&format('depth49'&&format('depth51'&&'depth51')))))))))) )))))))))) )))))", + "err": { + "kind": "parsing", + "value": "Exceeded max expression depth 50" + } + }, + { + "expr": "format('depth3'&&format('depth5'&&format('depth7'&&format('depth9'&&format('depth11'&&format('depth13'&&format('depth15'&&format('depth17'&&format('depth19'&&format('depth21'&&format('depth23'&&format('depth25'&&format('depth27'&&format('depth29'&&format('depth31'&&format('depth33'&&format('depth35'&&format('depth37'&&format('depth39'&&format('depth41'&&format('depth43'&&format('depth45'&&format('depth47'&&format('depth49'&&format('depth50')))))))))) )))))))))) )))))", + "result": { "kind": "String", "value": "depth50" } + }, + { + "expr": "1 || 2 || 3 || 4 || 5 || 6 || 7 || 8 || 9 || 10 || 11 || 12 || 13 || 14 || 15 || 16 || 17 || 18 || 19 || 20 || 21 || 22 || 23 || 24 || 25 || 26 || 27 || 28 || 29 || 30 || 31 || 32 || 33 || 34 || 35 || 36 || 37 || 38 || 39 || 40 || 41 || 42 || 43 || 44 || 45 || 46 || 47 || 48 || 49 || 50 || 51 || 52 || 53 || 54 || 55 || 56 || 57 || 58 || 59 || 60", + "result": { "kind": "Number", "value": 1 } + }, + { + "expr": "1 && 2 && 3 && 4 && 5 && 6 && 7 && 8 && 9 && 10 && 11 && 12 && 13 && 14 && 15 && 16 && 17 && 18 && 19 && 20 && 21 && 22 && 23 && 24 && 25 && 26 && 27 && 28 && 29 && 30 && 31 && 32 && 33 && 34 && 35 && 36 && 37 && 38 && 39 && 40 && 41 && 42 && 43 && 44 && 45 && 46 && 47 && 48 && 49 && 50 && 51 && 52 && 53 && 54 && 55 && 56 && 57 && 58 && 59 && 60", + "result": { "kind": "Number", "value": 60 } + }, + { + "expr": "1 && 2 || 3 && 4 || 5 && 6 || 7 && 8 || 9 && 10 || 11 && 12 || 13 && 14 || 15 && 16 || 17 && 18 || 19 && 20 || 21 && 22 || 23 && 24 || 25 && 26 || 27 && 28 || 29 && 30 || 31 && 32 || 33 && 34 || 35 && 36 || 37 && 38 || 39 && 40 || 41 && 42 || 43 && 44 || 45 && 46 || 47 && 48 || 49 && 50 || 51 && 52 || 53 && 54 || 55 && 56 || 57 && 58 || 59 && 60", + "result": { "kind": "Number", "value": 2 } + } + ], + "memory-errors": [ + { + "options": { + "skip": ["typescript"] + }, + "expr": "startswith(format('{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}', tojson(github)), format('{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}', tojson(github)))", + "contexts": { + "github": { + "ref": "refs/heads/main", + "sha": "2ce6b095f4cceb616efed52266e3e3c0367ba795", + "repository": "monalisa/testing", + "repository_owner": "monalisa", + "repository_owner_id": "12102068", + "repositoryUrl": "git://github.com/monalisa/testing.git", + "run_id": "2827197126", + "run_number": "845", + "retention_days": "90", + "run_attempt": "1", + "artifact_cache_size_limit": "10", + "repository_visibility": "public", + "repository_id": "1", + "actor_id": "1", + "actor": "monalisa", + "triggering_actor": "monalisa", + "workflow": "CI", + "head_ref": "", + "base_ref": "", + "event_name": "push", + "event": { + "after": "2ce6b095f4cceb616efed52266e3e3c0367ba795", + "base_ref": null, + "before": "bbe29778fdad33232cd67bd84205d334b94412d7", + "commits": [ + { + "author": { + "email": "monalisa@users.noreply.github.com", + "name": "mona", + "username": "monalisa" + }, + "committer": { + "email": "noreply@github.com", + "name": "GitHub", + "username": "web-flow" + }, + "distinct": true, + "id": "2ce6b095f4cceb616efed52266e3e3c0367ba795", + "message": "Update main.yml", + "timestamp": "2022-08-09T12:40:12-05:00", + "tree_id": "0dfd49aedd0cc103865d00587df3d23aa6f571f7", + "url": "https://github.com/monalisa/testing/commit/2ce6b095f4cceb616efed52266e3e3c0367ba795" + } + ], + "compare": "https://github.com/monalisa/testing/compare/bbe29778fdad...2ce6b095f4cc", + "created": false, + "deleted": false, + "forced": false, + "head_commit": { + "author": { + "email": "monalisa@users.noreply.github.com", + "name": "mona", + "username": "monalisa" + }, + "committer": { + "email": "noreply@github.com", + "name21": "GitHub", + "username": "web-flow" + }, + "distinct": true, + "id": "2ce6b095f4cceb616efed52266e3e3c0367ba795", + "message": "Update main.yml", + "timestamp": "2022-08-09T12:40:12-05:00", + "tree_id": "0dfd49aedd0cc103865d00587df3d23aa6f571f7", + "url": "https://github.com/monalisa/testing/commit/2ce6b095f4cceb616efed52266e3e3c0367ba795" + }, + "pusher": { + "email": "monalisa@users.noreply.github.com", + "name": "monalisa" + }, + "ref": "refs/heads/main", + "repository": { + "allow_forking": true, + "archive_url": "https://api.github.com/repos/monalisa/testing/{archive_format}{/ref}", + "archived": false, + "assignees_url": "https://api.github.com/repos/monalisa/testing/assignees{/user}", + "blobs_url": "https://api.github.com/repos/monalisa/testing/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/monalisa/testing/branches{/branch}", + "clone_url": "https://github.com/monalisa/testing.git", + "collaborators_url": "https://api.github.com/repos/monalisa/testing/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/monalisa/testing/comments{/number}", + "commits_url": "https://api.github.com/repos/monalisa/testing/commits{/sha}", + "compare_url": "https://api.github.com/repos/monalisa/testing/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/monalisa/testing/contents/{+path}", + "contributors_url": "https://api.github.com/repos/monalisa/testing/contributors", + "created_at": 1516230842, + "default_branch": "main", + "deployments_url": "https://api.github.com/repos/monalisa/testing/deployments", + "description": null, + "disabled": false, + "downloads_url": "https://api.github.com/repos/monalisa/testing/downloads", + "events_url": "https://api.github.com/repos/monalisa/testing/events", + "fork": false, + "forks": 1, + "forks_count": 1, + "forks_url": "https://api.github.com/repos/monalisa/testing/forks", + "full_name": "monalisa/testing", + "git_commits_url": "https://api.github.com/repos/monalisa/testing/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/monalisa/testing/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/monalisa/testing/git/tags{/sha}", + "git_url": "git://github.com/monalisa/testing.git", + "has_downloads": true, + "has_issues": true, + "has_pages": false, + "has_projects": true, + "has_wiki": true, + "homepage": null, + "hooks_url": "https://api.github.com/repos/monalisa/testing/hooks", + "html_url": "https://github.com/monalisa/testing", + "id": 117904191, + "is_template": false, + "issue_comment_url": "https://api.github.com/repos/monalisa/testing/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/monalisa/testing/issues/events{/number}", + "issues_url": "https://api.github.com/repos/monalisa/testing/issues{/number}", + "keys_url": "https://api.github.com/repos/monalisa/testing/keys{/key_id}", + "labels_url": "https://api.github.com/repos/monalisa/testing/labels{/name}", + "language": "Shell", + "languages_url": "https://api.github.com/repos/monalisa/testing/languages", + "license": null, + "master_branch": "main", + "merges_url": "https://api.github.com/repos/monalisa/testing/merges", + "milestones_url": "https://api.github.com/repos/monalisa/testing/milestones{/number}", + "mirror_url": null, + "name": "testing", + "node_id": "MDEwOlJlcG9zaXRvcnkxMTc5MDQxOTE=", + "notifications_url": "https://api.github.com/repos/monalisa/testing/notifications{?since,all,participating}", + "open_issues": 5, + "open_issues_count": 5, + "owner": { + "avatar_url": "https://avatars.githubusercontent.com/u/12102068?v=4", + "email": "monalisa@users.noreply.github.com", + "events_url": "https://api.github.com/users/monalisa/events{/privacy}", + "followers_url": "https://api.github.com/users/monalisa/followers", + "following_url": "https://api.github.com/users/monalisa/following{/other_user}", + "gists_url": "https://api.github.com/users/monalisa/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/monalisa", + "id": 12102068, + "login": "monalisa", + "name": "monalisa", + "node_id": "MDQ6VXNlcjEyMTAyMDY4", + "organizations_url": "https://api.github.com/users/monalisa/orgs", + "received_events_url": "https://api.github.com/users/monalisa/received_events", + "repos_url": "https://api.github.com/users/monalisa/repos", + "site_admin": true, + "starred_url": "https://api.github.com/users/monalisa/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/monalisa/subscriptions", + "type": "User", + "url": "https://api.github.com/users/monalisa" + }, + "private": false, + "pulls_url": "https://api.github.com/repos/monalisa/testing/pulls{/number}", + "pushed_at": 1660066812, + "releases_url": "https://api.github.com/repos/monalisa/testing/releases{/id}", + "size": 611, + "ssh_url": "git@github.com:monalisa/testing.git", + "stargazers": 1, + "stargazers_count": 1, + "stargazers_url": "https://api.github.com/repos/monalisa/testing/stargazers", + "statuses_url": "https://api.github.com/repos/monalisa/testing/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/monalisa/testing/subscribers", + "subscription_url": "https://api.github.com/repos/monalisa/testing/subscription", + "svn_url": "https://github.com/monalisa/testing", + "tags_url": "https://api.github.com/repos/monalisa/testing/tags", + "teams_url": "https://api.github.com/repos/monalisa/testing/teams", + "topics": [], + "trees_url": "https://api.github.com/repos/monalisa/testing/git/trees{/sha}", + "updated_at": "2022-01-05T02:14:32Z", + "url": "https://github.com/monalisa/testing", + "visibility": "public", + "watchers": 1, + "watchers_count": 1, + "web_commit_signoff_required": false + }, + "sender": { + "avatar_url": "https://avatars.githubusercontent.com/u/12102068?v=4", + "events_url": "https://api.github.com/users/monalisa/events{/privacy}", + "followers_url": "https://api.github.com/users/monalisa/followers", + "following_url": "https://api.github.com/users/monalisa/following{/other_user}", + "gists_url": "https://api.github.com/users/monalisa/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/monalisa", + "id": 12102068, + "login": "monalisa", + "node_id": "MDQ6VXNlcjEyMTAyMDY4", + "organizations_url": "https://api.github.com/users/monalisa/orgs", + "received_events_url": "https://api.github.com/users/monalisa/received_events", + "repos_url": "https://api.github.com/users/monalisa/repos", + "site_admin": true, + "starred_url": "https://api.github.com/users/monalisa/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/monalisa/subscriptions", + "type": "User", + "url": "https://api.github.com/users/monalisa" + } + }, + "server_url": "https://github.com", + "api_url": "https://api.github.com", + "graphql_url": "https://api.github.com/graphql", + "ref_name": "main", + "ref_protected": false, + "ref_type": "branch", + "secret_source": "Actions", + "workspace": "/home/runner/work/testing/testing", + "action": "__run_2", + "event_path": "/home/runner/work/_temp/_github_workflow/event.json", + "action_repository": "", + "action_ref": "", + "path": "/home/runner/work/_temp/_runner_file_commands/add_path_04146e5b-5a26-490d-b886-886641aa5651", + "env": "/home/runner/work/_temp/_runner_file_commands/set_env_04146e5b-5a26-490d-b886-886641aa5651", + "step_summary": "/home/runner/work/_temp/_runner_file_commands/step_summary_04146e5b-5a26-490d-b886-886641aa5651" + } + }, + "err": { + "kind": "evaluation", + "value": "The maximum allowed memory size was exceeded" + } + } + ] +} diff --git a/actions-expressions/testdata/tojson.json b/actions-expressions/testdata/tojson.json new file mode 100644 index 0000000..77b4cef --- /dev/null +++ b/actions-expressions/testdata/tojson.json @@ -0,0 +1,223 @@ +{ + "basics": [ + { + "expr": "tojson(null)", + "result": { + "kind": "String", + "value": "null" + } + }, + { + "expr": "tojson(true)", + "result": { + "kind": "String", + "value": "true" + } + }, + { + "expr": "tojson(false)", + "result": { + "kind": "String", + "value": "false" + } + }, + { + "expr": "tojson(0)", + "result": { + "kind": "String", + "value": "0" + } + }, + { + "expr": "tojson(-0)", + "result": { + "kind": "String", + "value": "0" + } + }, + { + "expr": "tojson(123456789)", + "result": { + "kind": "String", + "value": "123456789" + } + }, + { + "expr": "tojson(-123456789)", + "result": { + "kind": "String", + "value": "-123456789" + } + }, + { + "expr": "tojson(1234.5)", + "result": { + "kind": "String", + "value": "1234.5" + } + }, + { + "expr": "tojson(-1234.5)", + "result": { + "kind": "String", + "value": "-1234.5" + } + }, + { + "expr": "tojson('')", + "result": { + "kind": "String", + "value": "\"\"" + } + }, + { + "expr": "tojson('abc')", + "result": { + "kind": "String", + "value": "\"abc\"" + } + }, + { + "expr": "tojson('abc''def')", + "result": { + "kind": "String", + "value": "\"abc'def\"" + } + }, + { + "expr": "tojson('abc\\\"def')", + "result": { + "kind": "String", + "value": "\"abc\\\\\\\"def\"" + } + }, + { + "expr": "tojson(emptyArray)", + "contexts": { + "emptyArray": [] + }, + "result": { + "kind": "String", + "value": "[]" + } + }, + { + "expr": "tojson(emptyObject)", + "contexts": { + "emptyObject": {} + }, + "result": { + "kind": "String", + "value": "{}" + } + } + ], + "arrays": [ + { + "expr": "tojson(myArray)", + "contexts": { + "myArray": [] + }, + "result": { + "kind": "String", + "value": "[]" + } + }, + { + "expr": "tojson(myArray)", + "contexts": { + "myArray": [ + 1, + 2, + 3 + ] + }, + "result": { + "kind": "String", + "value": "[\n 1,\n 2,\n 3\n]" + } + }, + { + "expr": "tojson(myArray)", + "contexts": { + "myArray": [ + [ + 1, + 2, + 3 + ], + [ + "abc", + "def", + "ghi" + ], + [ + true, + false, + null, + [], + {} + ] + ] + }, + "result": { + "kind": "String", + "value": "[\n [\n 1,\n 2,\n 3\n ],\n [\n \"abc\",\n \"def\",\n \"ghi\"\n ],\n [\n true,\n false,\n null,\n [],\n {}\n ]\n]" + } + } + ], + "object": [ + { + "expr": "tojson(myObject)", + "contexts": { + "myObject": {} + }, + "result": { + "kind": "String", + "value": "{}" + } + }, + { + "expr": "tojson(myObject)", + "contexts": { + "myObject": { + "one": "value one", + "two" : "value two", + "three": "value three" + } + }, + "result": { + "kind": "String", + "value": "{\n \"one\": \"value one\",\n \"two\": \"value two\",\n \"three\": \"value three\"\n}" + } + }, + { + "expr": "tojson(myObject)", + "contexts": { + "myObject": { + "nested-one": { + "one": 1, + "two": 2, + "three": 3 + }, + "nested-two": { + "string one": "value one", + "string two": "value two", + "string three": "value three" + }, + "nested-three": { + "true": true, + "false": false, + "null": null, + "array": [], + "object": {} + } + } + }, + "result": { + "kind": "String", + "value": "{\n \"nested-one\": {\n \"one\": 1,\n \"two\": 2,\n \"three\": 3\n },\n \"nested-two\": {\n \"string one\": \"value one\",\n \"string two\": \"value two\",\n \"string three\": \"value three\"\n },\n \"nested-three\": {\n \"true\": true,\n \"false\": false,\n \"null\": null,\n \"array\": [],\n \"object\": {}\n }\n}" + } + } + ] +} diff --git a/actions-expressions/tsconfig.build.json b/actions-expressions/tsconfig.build.json new file mode 100644 index 0000000..88bdfa8 --- /dev/null +++ b/actions-expressions/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "exclude": ["./src/**/*.test.ts"], + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "./dist" + } +} diff --git a/actions-expressions/tsconfig.json b/actions-expressions/tsconfig.json new file mode 100644 index 0000000..bbc5196 --- /dev/null +++ b/actions-expressions/tsconfig.json @@ -0,0 +1,18 @@ +{ + "include": ["src"], + "compilerOptions": { + "module": "esnext", + "target": "ES2020", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "node" + }, + "watchOptions": { + "watchFile": "useFsEvents", + "watchDirectory": "useFsEvents", + "synchronousWatchDirectory": true, + "excludeDirectories": ["**/node_modules", "dist"] + } +} diff --git a/actions-workflow-parser/.eslintrc.json b/actions-workflow-parser/.eslintrc.json new file mode 100644 index 0000000..f0bbc84 --- /dev/null +++ b/actions-workflow-parser/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "root": true, + "rules": { + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off" + } +} diff --git a/actions-workflow-parser/.gitignore b/actions-workflow-parser/.gitignore new file mode 100644 index 0000000..c925c21 --- /dev/null +++ b/actions-workflow-parser/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/actions-workflow-parser/.npmrc b/actions-workflow-parser/.npmrc new file mode 100644 index 0000000..cd5193e --- /dev/null +++ b/actions-workflow-parser/.npmrc @@ -0,0 +1,2 @@ +@github:registry=https://npm.pkg.github.com + diff --git a/actions-workflow-parser/.prettierignore b/actions-workflow-parser/.prettierignore new file mode 100644 index 0000000..d50eb11 --- /dev/null +++ b/actions-workflow-parser/.prettierignore @@ -0,0 +1,3 @@ +/dist +/node_modules +/src/workflows/workflow-v1.0.json diff --git a/actions-workflow-parser/.prettierrc.json b/actions-workflow-parser/.prettierrc.json new file mode 100644 index 0000000..cce9d3c --- /dev/null +++ b/actions-workflow-parser/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "semi": false +} diff --git a/actions-workflow-parser/.vscode/launch.json b/actions-workflow-parser/.vscode/launch.json new file mode 100644 index 0000000..d5181ea --- /dev/null +++ b/actions-workflow-parser/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Jest Tests", + "type": "node", + "request": "launch", + "runtimeArgs": [ + "--inspect-brk", + "${workspaceRoot}/node_modules/jest/bin/jest.js", + "--runInBand" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "env": { + "NODE_OPTIONS": "--experimental-vm-modules" + } + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/jest.config.js b/actions-workflow-parser/jest.config.js new file mode 100644 index 0000000..70ba26a --- /dev/null +++ b/actions-workflow-parser/jest.config.js @@ -0,0 +1,15 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: "ts-jest/presets/default-esm", + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + }, + ], + }, +} diff --git a/actions-workflow-parser/package.json b/actions-workflow-parser/package.json new file mode 100644 index 0000000..25713c3 --- /dev/null +++ b/actions-workflow-parser/package.json @@ -0,0 +1,63 @@ +{ + "name": "@github/actions-workflow-parser", + "version": "0.0.42", + "license": "MIT", + "type": "module", + "source": "./src/index.ts", + "exports": { + ".": { + "import": "./dist/index.js" + }, + "./*": { + "import": "./dist/*.js" + } + }, + "typesVersions": { + "*": { + ".": [ + "dist/index.d.ts" + ], + "*": [ + "dist/*.d.ts" + ] + } + }, + "repository": { + "type": "git", + "url": "https://github.com/github/actions-workflow-parser/" + }, + "scripts": { + "build": "tsc --build tsconfig.build.json", + "clean": "rimraf dist", + "format": "prettier --write '**/*.ts'", + "format-check": "prettier --check '**/*.ts'", + "lint": "eslint **/*.ts", + "lint-fix": "eslint --fix **/*.ts", + "prepublishOnly": "npm run build && npm run test", + "test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest", + "test-xlang": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathPattern xlang", + "test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch", + "watch": "tsc --build tsconfig.build.json --watch" + }, + "dependencies": { + "@github/actions-expressions": "*", + "yaml": "^2.0.0-8" + }, + "engines": { + "node": ">= 16" + }, + "files": [ + "dist/**/*" + ], + "devDependencies": { + "@types/jest": "^29.0.3", + "@typescript-eslint/eslint-plugin": "^5.40.0", + "@typescript-eslint/parser": "^5.40.0", + "eslint": "7.32.0", + "jest": "^29.0.3", + "prettier": "^2.7.1", + "rimraf": "^3.0.2", + "ts-jest": "^29.0.3", + "typescript": "^4.8.4" + } +} \ No newline at end of file diff --git a/actions-workflow-parser/src/expressions.test.ts b/actions-workflow-parser/src/expressions.test.ts new file mode 100644 index 0000000..e306d99 --- /dev/null +++ b/actions-workflow-parser/src/expressions.test.ts @@ -0,0 +1,219 @@ +import { StringToken } from "./templates/tokens" +import { isBasicExpression, isString } from "./templates/tokens/type-guards" +import { nullTrace } from "./test-utils/null-trace" +import { parseWorkflow } from "./workflows/workflow-parser" + +describe("Workflow Expression Parsing", () => { + it("preserves original expressions when building format", () => { + const result = parseWorkflow( + "test.yaml", + [ + { + name: "test.yaml", + content: `on: push +run-name: Test \${{ github.event_name }} \${{ github.ref }} +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo 'hello'`, + }, + ], + nullTrace + ) + + expect(result.context.errors.getErrors()).toHaveLength(0) + + const run = result.value!.assertMapping("run")! + const runNameMapping = run.get(1)! + expect(runNameMapping?.key?.assertString("run-name key").value).toBe( + "run-name" + ) + + const v = runNameMapping.value! + expect(v).not.toBeUndefined() + + if (!isBasicExpression(v)) { + throw new Error("expected run-name to be a basic expression") + } + + expect(v.originalExpressions).toHaveLength(2) + expect(v.originalExpressions!.map((x) => x.toDisplayString())).toEqual([ + "${{ github.event_name }}", + "${{ github.ref }}", + ]) + }) + + it("preserves original expressions when building format for multi-line strings", () => { + const result = parseWorkflow( + "test.yaml", + [ + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + echo \${{ github.event_name }} + echo 'hello' \${{ github.ref }}`, + }, + ], + nullTrace + ) + + expect(result.context.errors.getErrors()).toHaveLength(0) + + const run = result.value!.assertMapping("run")! + const jobsMapping = run.get(1)! + expect(jobsMapping?.key?.assertString("jobs").value).toBe("jobs") + + const job = jobsMapping + .value!.assertMapping("jobs")! + .get(0)! + .value!.assertMapping("job") + const stepRun = job + .get(1) + .value!.assertSequence("steps") + .get(0) + .assertMapping("step") + .get(0) + .value!.assertScalar("step-run") + + if (!isBasicExpression(stepRun)) { + throw new Error("expected run-name to be a basic expression") + } + + expect(stepRun.originalExpressions).toHaveLength(2) + expect( + stepRun.originalExpressions!.map((x) => [x.toDisplayString(), x.range]) + ).toEqual([ + [ + "${{ github.event_name }}", + { + start: [7, 16], + end: [7, 40], + }, + ], + [ + "${{ github.ref }}", + { + start: [8, 24], + end: [8, 41], + }, + ], + ]) + }) + + it("return errors and string token with preserved expressions for (multiple) expression errors", () => { + const result = parseWorkflow( + "test.yaml", + [ + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + echo \${{ abc }} + echo 'hello' \${{ gith }}`, + }, + ], + nullTrace + ) + + expect(result.context.errors.getErrors()).toHaveLength(2) + + const run = result.value!.assertMapping("run")! + const jobsMapping = run.get(1)! + expect(jobsMapping?.key?.assertString("jobs").value).toBe("jobs") + + const job = jobsMapping + .value!.assertMapping("jobs")! + .get(0)! + .value!.assertMapping("job") + const stepRun = job + .get(1) + .value!.assertSequence("steps") + .get(0) + .assertMapping("step") + .get(0) + .value!.assertScalar("step-run") + + expect(isString(stepRun)).toBe(true) + expect((stepRun as StringToken).value).toContain("${{") + }) + + it("reports all errors for multi-line expressions at the correct locations", () => { + const result = parseWorkflow( + "test.yaml", + [ + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: | + echo \${{ fromJSON2('test') }} + echo 'hello' \${{ toJSON2(inputs.test) }}`, + }, + ], + nullTrace + ) + + expect(result.context.errors.getErrors()).toEqual([ + { + prefix: "test.yaml (Line: 6, Col: 14)", + range: { + start: [7, 16], + end: [7, 40], + }, + rawMessage: "Unrecognized function: 'fromJSON2'", + }, + { + prefix: "test.yaml (Line: 6, Col: 14)", + range: { + start: [8, 24], + end: [8, 51], + }, + rawMessage: "Unrecognized function: 'toJSON2'", + }, + ]) + }) + + it("parses isExpression strings into expression tokens", () => { + const result = parseWorkflow( + "test.yaml", + [ + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + if: github.event_name == 'push' + steps: + - run: echo 'hello'`, + }, + ], + nullTrace + ) + + expect(result.context.errors.getErrors()).toHaveLength(0) + + const workflowRoot = result.value!.assertMapping("root")! + const jobs = workflowRoot.get(1).value.assertMapping("jobs") + const build = jobs.get(0).value.assertMapping("job") + const ifToken = build.get(1).value + expect(ifToken.toString()).toEqual("${{ github.event_name == 'push' }}") + + if (!isBasicExpression(ifToken)) { + throw new Error("expected if to be a basic expression") + } + }) +}) diff --git a/actions-workflow-parser/src/index.test.ts b/actions-workflow-parser/src/index.test.ts new file mode 100644 index 0000000..4443f41 --- /dev/null +++ b/actions-workflow-parser/src/index.test.ts @@ -0,0 +1,121 @@ +import { TemplateValidationError } from "./templates/template-validation-error" +import { nullTrace } from "./test-utils/null-trace" +import { parseWorkflow } from "./workflows/workflow-parser" + +describe("parseWorkflow", () => { + it("parses valid workflow", () => { + const result = parseWorkflow( + "test.yaml", + [ + { + name: "test.yaml", + content: + "on: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'hello'", + }, + ], + nullTrace + ) + + expect(result.context.errors.getErrors()).toHaveLength(0) + }) + + it("contains range for error", () => { + const result = parseWorkflow( + "test.yaml", + [ + { + name: "test.yaml", + content: + "on: push\njobs:\n build:\n steps:\n - run: echo 'hello'", + }, + ], + nullTrace + ) + + expect(result.context.errors.getErrors()).toEqual([ + new TemplateValidationError( + "Required property is missing: runs-on", + "test.yaml (Line: 4, Col: 5)", + undefined, + { + start: [4, 5], + end: [5, 24], + } + ), + ]) + }) + + it("error range for expression is constrained to scalar node", () => { + const result = parseWorkflow( + "test.yaml", + [ + { + name: "test.yaml", + content: `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: \${{ github.event = 12 }} + run: echo 'hello'`, + }, + ], + nullTrace + ) + + expect(result.context.errors.getErrors()).toEqual([ + new TemplateValidationError( + "Unexpected symbol: '='. Located at position 14 within expression: github.event = 12", + "test.yaml (Line: 6, Col: 13)", + undefined, + { + start: [6, 13], + end: [6, 37], + } + ), + ]) + }) + + it("tokens contain descriptions", () => { + const result = parseWorkflow( + "test.yaml", + [ + { + name: "test.yaml", + content: + "on: push\nname: hello\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'hello'", + }, + ], + nullTrace + ) + + expect(result.context.errors.getErrors()).toHaveLength(0) + expect(result.value).not.toBeUndefined() + const root = result.value!.assertMapping("root") + expect(root.description).toBe("Workflow file with strict validation") + for (const pair of root) { + const key = pair.key.assertString("key").value + switch (key) { + case "name": { + const nameKey = pair.key.assertString("name") + expect(nameKey.definition).not.toBeUndefined() + expect(nameKey.description).toBe("The name of the workflow.") + break + } + case "on": { + const onKey = pair.key.assertString("on") + const onValue = pair.value.assertString("push") + expect(onKey.definition).not.toBeUndefined() + expect(onKey.description).toBe( + "The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows." + ) + expect(onValue.definition).not.toBeUndefined() + expect(onValue.description).toBe( + "Runs your workflow when you push a commit or tag." + ) + break + } + } + } + }) +}) diff --git a/actions-workflow-parser/src/index.ts b/actions-workflow-parser/src/index.ts new file mode 100644 index 0000000..e73faec --- /dev/null +++ b/actions-workflow-parser/src/index.ts @@ -0,0 +1,5 @@ +export { convertWorkflowTemplate } from "./model/convert" +export { WorkflowTemplate } from "./model/workflow-template" +export * from "./templates/tokens/type-guards" +export { NoOperationTraceWriter, TraceWriter } from "./templates/trace-writer" +export { parseWorkflow, ParseWorkflowResult } from "./workflows/workflow-parser" diff --git a/actions-workflow-parser/src/model/convert.test.ts b/actions-workflow-parser/src/model/convert.test.ts new file mode 100644 index 0000000..84e68e5 --- /dev/null +++ b/actions-workflow-parser/src/model/convert.test.ts @@ -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: [], + }) + }) +}) diff --git a/actions-workflow-parser/src/model/convert.ts b/actions-workflow-parser/src/model/convert.ts new file mode 100644 index 0000000..afab05d --- /dev/null +++ b/actions-workflow-parser/src/model/convert.ts @@ -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 +} diff --git a/actions-workflow-parser/src/model/converter/concurrency.ts b/actions-workflow-parser/src/model/converter/concurrency.ts new file mode 100644 index 0000000..544bbf3 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/concurrency.ts @@ -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 +} diff --git a/actions-workflow-parser/src/model/converter/container.ts b/actions-workflow-parser/src/model/converter/container.ts new file mode 100644 index 0000000..58edb75 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/container.ts @@ -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 } +} diff --git a/actions-workflow-parser/src/model/converter/events.ts b/actions-workflow-parser/src/model/converter/events.ts new file mode 100644 index 0000000..4504e2d --- /dev/null +++ b/actions-workflow-parser/src/model/converter/events.ts @@ -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( + 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 +} diff --git a/actions-workflow-parser/src/model/converter/handle-errors.ts b/actions-workflow-parser/src/model/converter/handle-errors.ts new file mode 100644 index 0000000..f8c8400 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/handle-errors.ts @@ -0,0 +1,27 @@ +import { TemplateContext } from "../../templates/template-context" +import { + TemplateToken, + TemplateTokenError, +} from "../../templates/tokens/template-token" + +export function handleTemplateTokenErrors( + 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 +} diff --git a/actions-workflow-parser/src/model/converter/id-builder.test.ts b/actions-workflow-parser/src/model/converter/id-builder.test.ts new file mode 100644 index 0000000..97ec3d1 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/id-builder.test.ts @@ -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" + ) + }) +}) diff --git a/actions-workflow-parser/src/model/converter/id-builder.ts b/actions-workflow-parser/src/model/converter/id-builder.ts new file mode 100644 index 0000000..b99bd08 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/id-builder.ts @@ -0,0 +1,126 @@ +const SEPARATOR = "_" +const MAX_ATTEMPTS = 1000 +const MAX_LENGTH = 100 + +export class IdBuilder { + private name: string[] = [] + private readonly distinctNames: Set = 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") + } +} diff --git a/actions-workflow-parser/src/model/converter/job/environment.ts b/actions-workflow-parser/src/model/converter/job/environment.ts new file mode 100644 index 0000000..5e9558f --- /dev/null +++ b/actions-workflow-parser/src/model/converter/job/environment.ts @@ -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 +} diff --git a/actions-workflow-parser/src/model/converter/jobs.ts b/actions-workflow-parser/src/model/converter/jobs.ts new file mode 100644 index 0000000..6b709c6 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/jobs.ts @@ -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 + 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 { + const labels = new Set() + 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 +} diff --git a/actions-workflow-parser/src/model/converter/steps.ts b/actions-workflow-parser/src/model/converter/steps.ts new file mode 100644 index 0000000..1aad070 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/steps.ts @@ -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 "" +} diff --git a/actions-workflow-parser/src/model/converter/string-list.ts b/actions-workflow-parser/src/model/converter/string-list.ts new file mode 100644 index 0000000..3de4584 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/string-list.ts @@ -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 +} diff --git a/actions-workflow-parser/src/model/converter/workflow-dispatch.ts b/actions-workflow-parser/src/model/converter/workflow-dispatch.ts new file mode 100644 index 0000000..a64cd2e --- /dev/null +++ b/actions-workflow-parser/src/model/converter/workflow-dispatch.ts @@ -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 +} diff --git a/actions-workflow-parser/src/model/type-guards.ts b/actions-workflow-parser/src/model/type-guards.ts new file mode 100644 index 0000000..23467c9 --- /dev/null +++ b/actions-workflow-parser/src/model/type-guards.ts @@ -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 +} diff --git a/actions-workflow-parser/src/model/workflow-template.ts b/actions-workflow-parser/src/model/workflow-template.ts new file mode 100644 index 0000000..dc9ec16 --- /dev/null +++ b/actions-workflow-parser/src/model/workflow-template.ts @@ -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[] +} diff --git a/actions-workflow-parser/src/templates/allowed-context.ts b/actions-workflow-parser/src/templates/allowed-context.ts new file mode 100644 index 0000000..1c5cd61 --- /dev/null +++ b/actions-workflow-parser/src/templates/allowed-context.ts @@ -0,0 +1,38 @@ +import { FunctionInfo } from "@github/actions-expressions/funcs/info" +import { MAX_CONSTANT } from "./template-constants" + +export function splitAllowedContext(allowedContext: string[]): { + namedContexts: string[] + functions: FunctionInfo[] +} { + const FUNCTION_REGEXP = /^([a-zA-Z0-9_]+)\(([0-9]+),([0-9]+|MAX)\)$/ + + const namedContexts: string[] = [] + const functions: FunctionInfo[] = [] + if (allowedContext.length > 0) { + for (const contextItem of allowedContext) { + const match = contextItem.match(FUNCTION_REGEXP) + if (match) { + const functionName = match[1] + const minParameters = Number.parseInt(match[2]) + const maxParametersRaw = match[3] + const maxParameters = + maxParametersRaw === MAX_CONSTANT + ? Number.MAX_SAFE_INTEGER + : Number.parseInt(maxParametersRaw) + functions.push({ + name: functionName, + minArgs: minParameters, + maxArgs: maxParameters, + }) + } else { + namedContexts.push(contextItem) + } + } + } + + return { + namedContexts: namedContexts, + functions: functions, + } +} diff --git a/actions-workflow-parser/src/templates/evaluate-expression.ts b/actions-workflow-parser/src/templates/evaluate-expression.ts new file mode 100644 index 0000000..13b92b3 --- /dev/null +++ b/actions-workflow-parser/src/templates/evaluate-expression.ts @@ -0,0 +1,211 @@ +import { Evaluator, Lexer, Parser } from "@github/actions-expressions" +import { Expr } from "@github/actions-expressions/ast" +import { + Dictionary, + ExpressionData, + Kind, +} from "@github/actions-expressions/data/index" +import { FunctionInfo } from "@github/actions-expressions/funcs/info" +import { TemplateContext } from "./template-context" +import { + BasicExpressionToken, + BooleanToken, + LiteralToken, + MappingToken, + NullToken, + NumberToken, + SequenceToken, + StringToken, + TemplateToken, +} from "./tokens" +import { TokenType } from "./tokens/types" + +export function evaluateStringToken( + token: BasicExpressionToken, + context: TemplateContext +): TemplateToken { + const expr = parseExpression( + token.expression, + context.expressionNamedContexts, + context.expressionFunctions + ) + if (!expr) { + throw new Error("Unexpected empty expression") + } + // TODO: Pass in context + const evaluator = new Evaluator(expr, new Dictionary()) + const result = evaluator.evaluate() + if (!result.primitive) { + context.error(token, "Expected a string") + return new StringToken( + token.file, + token.range, + token.expression, + token.definitionInfo + ) + } + + return new StringToken( + token.file, + token.range, + result.coerceString(), + token.definitionInfo + ) +} + +export function evaluateSequenceToken( + token: BasicExpressionToken, + context: TemplateContext +): TemplateToken { + const expr = parseExpression( + token.expression, + context.expressionNamedContexts, + context.expressionFunctions + ) + if (!expr) { + throw new Error("Unexpected empty expression") + } + // TODO: Pass in context + const evaluator = new Evaluator(expr, new Dictionary()) + const result = evaluator.evaluate() + const value = convertToTemplateToken(token, result) + if (value.templateTokenType !== TokenType.Sequence) { + context.error(token, "Expected a sequence") + return new SequenceToken(token.file, token.range, token.definitionInfo) + } + return value +} + +export function evaluateMappingToken( + token: BasicExpressionToken, + context: TemplateContext +): MappingToken { + const expr = parseExpression( + token.expression, + context.expressionNamedContexts, + context.expressionFunctions + ) + if (!expr) { + throw new Error("Unexpected empty expression") + } + // TODO: Pass in context + const evaluator = new Evaluator(expr, new Dictionary()) + const result = evaluator.evaluate() + const value = convertToTemplateToken(token, result) + if (value.templateTokenType !== TokenType.Mapping) { + context.error(token, "Expected a mapping") + return new MappingToken(token.file, token.range, token.definitionInfo) + } + return value as MappingToken +} + +export function evaluateTemplateToken( + token: BasicExpressionToken, + context: TemplateContext +): TemplateToken { + const expr = parseExpression( + token.expression, + context.expressionNamedContexts, + context.expressionFunctions + ) + if (!expr) { + throw new Error("Unexpected empty expression") + } + // TODO: Pass in context + const evaluator = new Evaluator(expr, new Dictionary()) + const result = evaluator.evaluate() + return convertToTemplateToken(token, result) +} + +function convertToTemplateToken( + token: BasicExpressionToken, + result: ExpressionData +): TemplateToken { + // Literal + const literal = convertToLiteralToken(token, result) + if (literal) { + return literal + } + + // TODO: Support expressions that return a sequence or mapping token + + // Leverage the expression SDK to traverse the object + switch (result.kind) { + case Kind.Dictionary: { + const mapping = new MappingToken( + token.file, + token.range, + token.definitionInfo + ) + for (const { key, value } of result.pairs()) { + const keyToken = new StringToken( + token.file, + token.range, + key, + token.definitionInfo + ) + const valueToken = convertToTemplateToken(token, value) + mapping.add(keyToken, valueToken) + } + return mapping + } + case Kind.Array: { + const sequence = new SequenceToken( + token.file, + token.range, + token.definitionInfo + ) + for (const value of result.values()) { + const itemToken = convertToTemplateToken(token, value) + sequence.add(itemToken) + } + return sequence + } + default: + throw new Error("Unable to convert the object to a template token") + } +} + +function convertToLiteralToken( + token: BasicExpressionToken, + result: ExpressionData +): LiteralToken | undefined { + switch (result.kind) { + case Kind.Null: + return new NullToken(token.file, token.range, token.definitionInfo) + case Kind.Boolean: + return new BooleanToken( + token.file, + token.range, + result.value, + token.definitionInfo + ) + case Kind.Number: + return new NumberToken( + token.file, + token.range, + result.value, + token.definitionInfo + ) + case Kind.String: + return new StringToken( + token.file, + token.range, + result.value, + token.definitionInfo + ) + } + + return undefined +} + +function parseExpression( + expression: string, + contexts: string[], + functions: FunctionInfo[] +): Expr { + const lexer = new Lexer(expression) + const result = lexer.lex() + const p = new Parser(result.tokens, contexts, functions) + return p.parse() +} diff --git a/actions-workflow-parser/src/templates/json-object-reader.ts b/actions-workflow-parser/src/templates/json-object-reader.ts new file mode 100644 index 0000000..b93f466 --- /dev/null +++ b/actions-workflow-parser/src/templates/json-object-reader.ts @@ -0,0 +1,193 @@ +import { ObjectReader } from "./object-reader" +import { EventType, ParseEvent } from "./parse-event" +import { + LiteralToken, + SequenceToken, + MappingToken, + NullToken, + BooleanToken, + NumberToken, + StringToken, +} from "./tokens" + +export class JSONObjectReader implements ObjectReader { + private readonly _fileId: number | undefined + private readonly _generator: Generator + private _current: IteratorResult + + public constructor(fileId: number | undefined, input: string) { + this._fileId = fileId + const value = JSON.parse(input) + this._generator = this.getParseEvents(value, true) + this._current = this._generator.next() + } + + public allowLiteral(): LiteralToken | undefined { + if (!this._current.done) { + const parseEvent = this._current.value + if (parseEvent.type === EventType.Literal) { + this._current = this._generator.next() + return parseEvent.token as LiteralToken + } + } + + return undefined + } + + public allowSequenceStart(): SequenceToken | undefined { + if (!this._current.done) { + const parseEvent = this._current.value + if (parseEvent.type === EventType.SequenceStart) { + this._current = this._generator.next() + return parseEvent.token as SequenceToken + } + } + + return undefined + } + + public allowSequenceEnd(): boolean { + if (!this._current.done) { + const parseEvent = this._current.value + if (parseEvent.type === EventType.SequenceEnd) { + this._current = this._generator.next() + return true + } + } + + return false + } + + public allowMappingStart(): MappingToken | undefined { + if (!this._current.done) { + const parseEvent = this._current.value + if (parseEvent.type === EventType.MappingStart) { + this._current = this._generator.next() + return parseEvent.token as MappingToken + } + } + + return undefined + } + + public allowMappingEnd(): boolean { + if (!this._current.done) { + const parseEvent = this._current.value + if (parseEvent.type === EventType.MappingEnd) { + this._current = this._generator.next() + return true + } + } + + return false + } + + public validateEnd(): void { + if (!this._current.done) { + const parseEvent = this._current.value as ParseEvent + if (parseEvent.type === EventType.DocumentEnd) { + this._current = this._generator.next() + return + } + } + + throw new Error("Expected end of reader") + } + + public validateStart(): void { + if (!this._current.done) { + const parseEvent = this._current.value as ParseEvent + if (parseEvent.type === EventType.DocumentStart) { + this._current = this._generator.next() + return + } + } + + throw new Error("Expected start of reader") + } + + /** + * Returns all tokens (depth first) + */ + private *getParseEvents( + value: any, + root?: boolean + ): Generator { + if (root) { + yield new ParseEvent(EventType.DocumentStart, undefined) + } + switch (typeof value) { + case "undefined": + yield new ParseEvent( + EventType.Literal, + new NullToken(this._fileId, undefined, undefined) + ) + break + case "boolean": + yield new ParseEvent( + EventType.Literal, + new BooleanToken(this._fileId, undefined, value, undefined) + ) + break + case "number": + yield new ParseEvent( + EventType.Literal, + new NumberToken(this._fileId, undefined, value, undefined) + ) + break + case "string": + yield new ParseEvent( + EventType.Literal, + new StringToken(this._fileId, undefined, value, undefined) + ) + break + case "object": + // null + if (value === null) { + yield new ParseEvent( + EventType.Literal, + new NullToken(this._fileId, undefined, undefined) + ) + } + // array + else if (Object.prototype.hasOwnProperty.call(value, "length")) { + yield new ParseEvent( + EventType.SequenceStart, + new SequenceToken(this._fileId, undefined, undefined) + ) + for (const item of value as []) { + for (const e of this.getParseEvents(item)) { + yield e + } + } + yield new ParseEvent(EventType.SequenceEnd, undefined) + } + // object + else { + yield new ParseEvent( + EventType.MappingStart, + new MappingToken(this._fileId, undefined, undefined) + ) + for (const key of Object.keys(value)) { + yield new ParseEvent( + EventType.Literal, + new StringToken(this._fileId, undefined, key, undefined) + ) + for (const e of this.getParseEvents(value[key])) { + yield e + } + } + yield new ParseEvent(EventType.MappingEnd, undefined) + } + break + default: + throw new Error( + `Unexpected value type '${typeof value}' when reading object` + ) + } + + if (root) { + yield new ParseEvent(EventType.DocumentEnd, undefined) + } + } +} diff --git a/actions-workflow-parser/src/templates/object-reader.ts b/actions-workflow-parser/src/templates/object-reader.ts new file mode 100644 index 0000000..e89bbb8 --- /dev/null +++ b/actions-workflow-parser/src/templates/object-reader.ts @@ -0,0 +1,22 @@ +import { LiteralToken, SequenceToken, MappingToken } from "./tokens" + +/** + * Interface for reading a source object (or file). + * This interface is used by TemplateReader to build a TemplateToken DOM. + */ +export interface ObjectReader { + allowLiteral(): LiteralToken | undefined + + // maybe rename these since we don't have out params + allowSequenceStart(): SequenceToken | undefined + + allowSequenceEnd(): boolean + + allowMappingStart(): MappingToken | undefined + + allowMappingEnd(): boolean + + validateStart(): void + + validateEnd(): void +} diff --git a/actions-workflow-parser/src/templates/parse-event.ts b/actions-workflow-parser/src/templates/parse-event.ts new file mode 100644 index 0000000..36215a2 --- /dev/null +++ b/actions-workflow-parser/src/templates/parse-event.ts @@ -0,0 +1,20 @@ +import { TemplateToken } from "./tokens" + +export class ParseEvent { + public readonly type: EventType + public readonly token: TemplateToken | undefined + public constructor(type: EventType, token?: TemplateToken | undefined) { + this.type = type + this.token = token + } +} + +export enum EventType { + Literal, + SequenceStart, + SequenceEnd, + MappingStart, + MappingEnd, + DocumentStart, + DocumentEnd, +} diff --git a/actions-workflow-parser/src/templates/schema/boolean-definition.ts b/actions-workflow-parser/src/templates/schema/boolean-definition.ts new file mode 100644 index 0000000..e4cdd92 --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/boolean-definition.ts @@ -0,0 +1,52 @@ +import { TemplateSchema } from "." +import { DEFINITION, BOOLEAN } from "../template-constants" +import { MappingToken, LiteralToken } from "../tokens" +import { TokenType } from "../tokens/types" +import { DefinitionType } from "./definition-type" +import { ScalarDefinition } from "./scalar-definition" + +export class BooleanDefinition extends ScalarDefinition { + public constructor(key: string, definition?: MappingToken) { + super(key, definition) + if (definition) { + for (const definitionPair of definition) { + const definitionKey = definitionPair.key.assertString( + `${DEFINITION} key` + ) + switch (definitionKey.value) { + case BOOLEAN: { + const mapping = definitionPair.value.assertMapping( + `${DEFINITION} ${BOOLEAN}` + ) + for (const mappingPair of mapping) { + const mappingKey = mappingPair.key.assertString( + `${DEFINITION} ${BOOLEAN} key` + ) + switch (mappingKey.value) { + default: + // throws + mappingKey.assertUnexpectedValue( + `${DEFINITION} ${BOOLEAN} key` + ) + break + } + } + break + } + default: + definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws + } + } + } + } + + public override get definitionType(): DefinitionType { + return DefinitionType.Boolean + } + + public override isMatch(literal: LiteralToken): boolean { + return literal.templateTokenType === TokenType.Boolean + } + + public override validate(schema: TemplateSchema, name: string): void {} +} diff --git a/actions-workflow-parser/src/templates/schema/definition-info.ts b/actions-workflow-parser/src/templates/schema/definition-info.ts new file mode 100644 index 0000000..b8e5e76 --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/definition-info.ts @@ -0,0 +1,63 @@ +import { TemplateSchema } from "." +import { Definition } from "./definition" +import { DefinitionType } from "./definition-type" +import { ScalarDefinition } from "./scalar-definition" + +export class DefinitionInfo { + private readonly _schema: TemplateSchema + public readonly isDefinitionInfo = true + public readonly definition: Definition + public readonly allowedContext: string[] + + public constructor(schema: TemplateSchema, name: string) + public constructor(parent: DefinitionInfo, name: string) + public constructor(parent: DefinitionInfo, definition: Definition) + public constructor( + schemaOrParent: TemplateSchema | DefinitionInfo, + nameOrDefinition: string | Definition + ) { + const parent: DefinitionInfo | undefined = + (schemaOrParent as DefinitionInfo | undefined)?.isDefinitionInfo === true + ? (schemaOrParent as DefinitionInfo) + : undefined + this._schema = + parent === undefined ? (schemaOrParent as TemplateSchema) : parent._schema + + // Lookup the definition if a key was passed in + this.definition = + typeof nameOrDefinition === "string" + ? this._schema.getDefinition(nameOrDefinition) + : nameOrDefinition + + // Record allowed context + if (this.definition.readerContext.length > 0) { + this.allowedContext = [] + + // Copy parent allowed context + const upperSeen: { [upper: string]: boolean } = {} + for (const context of parent?.allowedContext ?? []) { + this.allowedContext.push(context) + upperSeen[context.toUpperCase()] = true + } + + // Append context if unseen + for (const context of this.definition.readerContext) { + const upper = context.toUpperCase() + if (!upperSeen[upper]) { + this.allowedContext.push(context) + upperSeen[upper] = true + } + } + } else { + this.allowedContext = parent?.allowedContext ?? [] + } + } + + public getScalarDefinitions(): ScalarDefinition[] { + return this._schema.getScalarDefinitions(this.definition) + } + + public getDefinitionsOfType(type: DefinitionType): Definition[] { + return this._schema.getDefinitionsOfType(this.definition, type) + } +} diff --git a/actions-workflow-parser/src/templates/schema/definition-type.ts b/actions-workflow-parser/src/templates/schema/definition-type.ts new file mode 100644 index 0000000..e7194ce --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/definition-type.ts @@ -0,0 +1,10 @@ +export enum DefinitionType { + Null, + Boolean, + Number, + String, + Sequence, + Mapping, + OneOf, + AllowedValues, +} diff --git a/actions-workflow-parser/src/templates/schema/definition.ts b/actions-workflow-parser/src/templates/schema/definition.ts new file mode 100644 index 0000000..e7a1ead --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/definition.ts @@ -0,0 +1,85 @@ +import { CONTEXT, DEFINITION, DESCRIPTION } from "../template-constants" +import { MappingToken } from "../tokens" +import { DefinitionType } from "./definition-type" +import { TemplateSchema } from "./template-schema" + +/** + * Defines the allowable schema for a user defined type + */ +export abstract class Definition { + /** + * Used by the template reader to determine allowed expression values and functions. + * Also used by the template reader to validate function min/max parameters. + */ + public readonly readerContext: string[] = [] + + /** + * Used by the template evaluator to determine allowed expression values and functions. + * The min/max parameter info is omitted. + */ + public readonly evaluatorContext: string[] = [] + + // A key to uniquely identify a definition + public readonly key: string + + public description: string | undefined + + public constructor(key: string, definition?: MappingToken) { + this.key = key + if (definition) { + for (let i = 0; i < definition.count; ) { + const definitionKey = definition + .get(i) + .key.assertString(`${DEFINITION} key`) + switch (definitionKey.value) { + case CONTEXT: { + const context = definition + .get(i) + .value.assertSequence(`${DEFINITION} ${CONTEXT}`) + definition.remove(i) + const seenReaderContext: { [key: string]: boolean } = {} + const seenEvaluatorContext: { [key: string]: boolean } = {} + for (const item of context) { + const itemStr = item.assertString(`${CONTEXT} item`).value + const upperItemStr = itemStr.toUpperCase() + if (seenReaderContext[upperItemStr]) { + throw new Error(`Duplicate context item '${itemStr}'`) + } + seenReaderContext[upperItemStr] = true + this.readerContext.push(itemStr) + + // Remove min/max parameter info + const paramIndex = itemStr.indexOf("(") + const modifiedItemStr = + paramIndex > 0 + ? itemStr.substr(0, paramIndex + 1) + ")" + : itemStr + const upperModifiedItemStr = modifiedItemStr.toUpperCase() + if (seenEvaluatorContext[upperModifiedItemStr]) { + throw new Error(`Duplicate context item '${modifiedItemStr}'`) + } + seenEvaluatorContext[upperModifiedItemStr] = true + this.evaluatorContext.push(modifiedItemStr) + } + + break + } + case DESCRIPTION: { + const value = definition.get(i).value + this.description = value.assertString(DESCRIPTION).value + definition.remove(i) + break + } + default: { + i++ + break + } + } + } + } + } + + public abstract get definitionType(): DefinitionType + + public abstract validate(schema: TemplateSchema, name: string): void +} diff --git a/actions-workflow-parser/src/templates/schema/index.ts b/actions-workflow-parser/src/templates/schema/index.ts new file mode 100644 index 0000000..2b09e24 --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/index.ts @@ -0,0 +1 @@ +export { TemplateSchema } from "./template-schema" diff --git a/actions-workflow-parser/src/templates/schema/mapping-definition.ts b/actions-workflow-parser/src/templates/schema/mapping-definition.ts new file mode 100644 index 0000000..90ee21a --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/mapping-definition.ts @@ -0,0 +1,117 @@ +import { TemplateSchema } from "./template-schema" +import { + DEFINITION, + MAPPING, + PROPERTIES, + LOOSE_KEY_TYPE, + LOOSE_VALUE_TYPE, +} from "../template-constants" +import { MappingToken } from "../tokens" +import { Definition } from "./definition" +import { DefinitionType } from "./definition-type" +import { PropertyDefinition } from "./property-definition" + +export class MappingDefinition extends Definition { + public readonly properties: { [key: string]: PropertyDefinition } = {} + public looseKeyType = "" + public looseValueType = "" + + public constructor(key: string, definition?: MappingToken) { + super(key, definition) + if (definition) { + for (const definitionPair of definition) { + const definitionKey = definitionPair.key.assertString( + `${DEFINITION} key` + ) + switch (definitionKey.value) { + case MAPPING: { + const mapping = definitionPair.value.assertMapping( + `${DEFINITION} ${MAPPING}` + ) + for (const mappingPair of mapping) { + const mappingKey = mappingPair.key.assertString( + `${DEFINITION} ${MAPPING} key` + ) + switch (mappingKey.value) { + case PROPERTIES: { + const properties = mappingPair.value.assertMapping( + `${DEFINITION} ${MAPPING} ${PROPERTIES}` + ) + for (const propertiesPair of properties) { + const propertyName = propertiesPair.key.assertString( + `${DEFINITION} ${MAPPING} ${PROPERTIES} key` + ) + this.properties[propertyName.value] = + new PropertyDefinition(propertiesPair.value) + } + break + } + case LOOSE_KEY_TYPE: { + const looseKeyType = mappingPair.value.assertString( + `${DEFINITION} ${MAPPING} ${LOOSE_KEY_TYPE}` + ) + this.looseKeyType = looseKeyType.value + break + } + case LOOSE_VALUE_TYPE: { + const looseValueType = mappingPair.value.assertString( + `${DEFINITION} ${MAPPING} ${LOOSE_VALUE_TYPE}` + ) + this.looseValueType = looseValueType.value + break + } + default: + // throws + mappingKey.assertUnexpectedValue( + `${DEFINITION} ${MAPPING} key` + ) + break + } + } + break + } + default: + definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws + } + } + } + } + + public override get definitionType(): DefinitionType { + return DefinitionType.Mapping + } + + public override validate(schema: TemplateSchema, name: string): void { + // Lookup loose key type + if (this.looseKeyType) { + schema.getDefinition(this.looseKeyType) + + // Lookup loose value type + if (this.looseValueType) { + schema.getDefinition(this.looseValueType) + } else { + throw new Error( + `Property '${LOOSE_KEY_TYPE}' is defined but '${LOOSE_VALUE_TYPE}' is not defined on '${name}'` + ) + } + } + // Otherwise validate loose value type not be defined + else if (this.looseValueType) { + throw new Error( + `Property '${LOOSE_VALUE_TYPE}' is defined but '${LOOSE_KEY_TYPE}' is not defined on '${name}'` + ) + } + + // Lookup each property + for (const propertyName of Object.keys(this.properties)) { + const propertyDef = this.properties[propertyName] + if (!propertyDef.type) { + throw new Error( + `Type not specified for the property '${propertyName}' on '${name}'` + ) + } + + schema.getDefinition(propertyDef.type) + } + } +} diff --git a/actions-workflow-parser/src/templates/schema/null-definition.ts b/actions-workflow-parser/src/templates/schema/null-definition.ts new file mode 100644 index 0000000..6f23bfa --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/null-definition.ts @@ -0,0 +1,50 @@ +import { TemplateSchema } from "./template-schema" +import { DEFINITION, NULL } from "../template-constants" +import { MappingToken, LiteralToken } from "../tokens" +import { DefinitionType } from "./definition-type" +import { ScalarDefinition } from "./scalar-definition" +import { TokenType } from "../tokens/types" + +export class NullDefinition extends ScalarDefinition { + public constructor(key: string, definition?: MappingToken) { + super(key, definition) + if (definition) { + for (const definitionPair of definition) { + const definitionKey = definitionPair.key.assertString( + `${DEFINITION} key` + ) + switch (definitionKey.value) { + case NULL: { + const mapping = definitionPair.value.assertMapping( + `${DEFINITION} ${NULL}` + ) + for (const mappingPair of mapping) { + const mappingKey = mappingPair.key.assertString( + `${DEFINITION} ${NULL} key` + ) + switch (mappingKey.value) { + default: + // throws + mappingKey.assertUnexpectedValue(`${DEFINITION} ${NULL} key`) + break + } + } + break + } + default: + definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws + } + } + } + } + + public override get definitionType(): DefinitionType { + return DefinitionType.Null + } + + public override isMatch(literal: LiteralToken): boolean { + return literal.templateTokenType === TokenType.Null + } + + public override validate(schema: TemplateSchema, name: string): void {} +} diff --git a/actions-workflow-parser/src/templates/schema/number-definition.ts b/actions-workflow-parser/src/templates/schema/number-definition.ts new file mode 100644 index 0000000..74dc982 --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/number-definition.ts @@ -0,0 +1,52 @@ +import { TemplateSchema } from "./template-schema" +import { DEFINITION, NUMBER } from "../template-constants" +import { MappingToken, LiteralToken } from "../tokens" +import { DefinitionType } from "./definition-type" +import { ScalarDefinition } from "./scalar-definition" +import { TokenType } from "../tokens/types" + +export class NumberDefinition extends ScalarDefinition { + public constructor(key: string, definition?: MappingToken) { + super(key, definition) + if (definition) { + for (const definitionPair of definition) { + const definitionKey = definitionPair.key.assertString( + `${DEFINITION} key` + ) + switch (definitionKey.value) { + case NUMBER: { + const mapping = definitionPair.value.assertMapping( + `${DEFINITION} ${NUMBER}` + ) + for (const mappingPair of mapping) { + const mappingKey = mappingPair.key.assertString( + `${DEFINITION} ${NUMBER} key` + ) + switch (mappingKey.value) { + default: + // throws + mappingKey.assertUnexpectedValue( + `${DEFINITION} ${NUMBER} key` + ) + break + } + } + break + } + default: + definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws + } + } + } + } + + public override get definitionType(): DefinitionType { + return DefinitionType.Number + } + + public override isMatch(literal: LiteralToken): boolean { + return literal.templateTokenType === TokenType.Number + } + + public override validate(schema: TemplateSchema, name: string): void {} +} diff --git a/actions-workflow-parser/src/templates/schema/one-of-definition.ts b/actions-workflow-parser/src/templates/schema/one-of-definition.ts new file mode 100644 index 0000000..ec1e4bb --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/one-of-definition.ts @@ -0,0 +1,223 @@ +import { TemplateSchema } from "./template-schema" +import { + DEFINITION, + ONE_OF, + SEQUENCE, + NULL, + BOOLEAN, + NUMBER, + SCALAR, + CONSTANT, + LOOSE_KEY_TYPE, + ALLOWED_VALUES, +} from "../template-constants" +import { MappingToken } from "../tokens" +import { BooleanDefinition } from "./boolean-definition" +import { Definition } from "./definition" +import { DefinitionType } from "./definition-type" +import { MappingDefinition } from "./mapping-definition" +import { NullDefinition } from "./null-definition" +import { NumberDefinition } from "./number-definition" +import { SequenceDefinition } from "./sequence-definition" +import { StringDefinition } from "./string-definition" +import { PropertyDefinition } from "./property-definition" + +/** + * Must resolve to exactly one of the referenced definitions + */ +export class OneOfDefinition extends Definition { + public readonly oneOf: string[] = [] + public readonly oneOfPrefix: string[] = [] + + public constructor(key: string, definition?: MappingToken) { + super(key, definition) + if (definition) { + for (const definitionPair of definition) { + const definitionKey = definitionPair.key.assertString( + `${DEFINITION} key` + ) + switch (definitionKey.value) { + case ONE_OF: { + const oneOf = definitionPair.value.assertSequence( + `${DEFINITION} ${ONE_OF}` + ) + for (const item of oneOf) { + const oneOfItem = item.assertString( + `${DEFINITION} ${ONE_OF} item` + ) + this.oneOf.push(oneOfItem.value) + } + break + } + case ALLOWED_VALUES: { + const oneOf = definitionPair.value.assertSequence( + `${DEFINITION} ${ALLOWED_VALUES}` + ) + for (const item of oneOf) { + const oneOfItem = item.assertString( + `${DEFINITION} ${ONE_OF} item` + ) + this.oneOf.push(this.key + "-" + oneOfItem.value) + } + break + } + default: + // throws + definitionKey.assertUnexpectedValue(`${DEFINITION} key`) + break + } + } + } + } + + public override get definitionType(): DefinitionType { + return DefinitionType.OneOf + } + + public override validate(schema: TemplateSchema, name: string): void { + if (this.oneOf.length === 0) { + throw new Error(`'${name}' does not contain any references`) + } + + let foundLooseKeyType = false + const mappingDefinitions: MappingDefinition[] = [] + let allowedValuesDefinition: OneOfDefinition | undefined + let sequenceDefinition: SequenceDefinition | undefined + let nullDefinition: NullDefinition | undefined + let booleanDefinition: BooleanDefinition | undefined + let numberDefinition: NumberDefinition | undefined + const stringDefinitions: StringDefinition[] = [] + const seenNestedTypes: { [key: string]: boolean } = {} + + for (const nestedType of this.oneOf) { + if (seenNestedTypes[nestedType]) { + throw new Error( + `'${name}' contains duplicate nested type '${nestedType}'` + ) + } + seenNestedTypes[nestedType] = true + + const nestedDefinition = schema.getDefinition(nestedType) + if (nestedDefinition.readerContext.length > 0) { + throw new Error( + `'${name}' is a one-of definition and references another definition that defines context. This is currently not supported.` + ) + } + + switch (nestedDefinition.definitionType) { + case DefinitionType.Mapping: { + const mappingDefinition = nestedDefinition as MappingDefinition + mappingDefinitions.push(mappingDefinition) + if (mappingDefinition.looseKeyType) { + foundLooseKeyType = true + } + break + } + case DefinitionType.Sequence: { + // Multiple sequence definitions not allowed + if (sequenceDefinition) { + throw new Error( + `'${name}' refers to more than one definition of type '${SEQUENCE}'` + ) + } + sequenceDefinition = nestedDefinition as SequenceDefinition + break + } + case DefinitionType.Null: { + // Multiple null definitions not allowed + if (nullDefinition) { + throw new Error( + `'${name}' refers to more than one definition of type '${NULL}'` + ) + } + nullDefinition = nestedDefinition as NullDefinition + break + } + case DefinitionType.Boolean: { + // Multiple boolean definitions not allowed + if (booleanDefinition) { + throw new Error( + `'${name}' refers to more than one definition of type '${BOOLEAN}'` + ) + } + booleanDefinition = nestedDefinition as BooleanDefinition + break + } + case DefinitionType.Number: { + // Multiple number definitions not allowed + if (numberDefinition) { + throw new Error( + `'${name}' refers to more than one definition of type '${NUMBER}'` + ) + } + numberDefinition = nestedDefinition as NumberDefinition + break + } + case DefinitionType.String: { + const stringDefinition = nestedDefinition as StringDefinition + + // Multiple string definitions + if ( + stringDefinitions.length > 0 && + (!stringDefinitions[0].constant || !stringDefinition.constant) + ) { + throw new Error( + `'${name}' refers to more than one '${SCALAR}', but some do not set '${CONSTANT}'` + ) + } + + stringDefinitions.push(stringDefinition) + break + } + case DefinitionType.OneOf: { + // Multiple allowed-values definitions not allowed + if (allowedValuesDefinition) { + throw new Error( + `'${name}' contains multiple allowed-values definitions` + ) + } + allowedValuesDefinition = nestedDefinition as OneOfDefinition + break + } + default: + throw new Error( + `'${name}' refers to a definition with type '${nestedDefinition.definitionType}'` + ) + } + } + + if (mappingDefinitions.length > 1) { + if (foundLooseKeyType) { + throw new Error( + `'${name}' refers to two mappings and at least one sets '${LOOSE_KEY_TYPE}'. This is not currently supported.` + ) + } + + const seenProperties: { [key: string]: PropertyDefinition } = {} + for (const mappingDefinition of mappingDefinitions) { + for (const propertyName of Object.keys(mappingDefinition.properties)) { + const newPropertyDef = mappingDefinition.properties[propertyName] + + // Already seen + const existingPropertyDef: PropertyDefinition | undefined = + seenProperties[propertyName] + if (existingPropertyDef) { + // Types match + if (existingPropertyDef.type === newPropertyDef.type) { + continue + } + + // Collision + throw new Error( + `'${name}' contains two mappings with the same property, but each refers to a different type. All matching properties must refer to the same type.` + ) + } + // New + else { + seenProperties[propertyName] = newPropertyDef + } + } + } + } + } +} diff --git a/actions-workflow-parser/src/templates/schema/property-definition.ts b/actions-workflow-parser/src/templates/schema/property-definition.ts new file mode 100644 index 0000000..1a94683 --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/property-definition.ts @@ -0,0 +1,46 @@ +import { + MAPPING_PROPERTY_VALUE, + TYPE, + REQUIRED, + DESCRIPTION, +} from "../template-constants" +import { TemplateToken, StringToken } from "../tokens" +import { TokenType } from "../tokens/types" + +export class PropertyDefinition { + public readonly type: string = "" + public readonly required: boolean = false + public description: string | undefined + + public constructor(token: TemplateToken) { + if (token.templateTokenType === TokenType.String) { + this.type = (token as StringToken).value + } else { + const mapping = token.assertMapping(MAPPING_PROPERTY_VALUE) + for (const mappingPair of mapping) { + const mappingKey = mappingPair.key.assertString( + `${MAPPING_PROPERTY_VALUE} key` + ) + switch (mappingKey.value) { + case TYPE: + this.type = mappingPair.value.assertString( + `${MAPPING_PROPERTY_VALUE} ${TYPE}` + ).value + break + case REQUIRED: + this.required = mappingPair.value.assertBoolean( + `${MAPPING_PROPERTY_VALUE} ${REQUIRED}` + ).value + break + case DESCRIPTION: + this.description = mappingPair.value.assertString( + `${MAPPING_PROPERTY_VALUE} ${DESCRIPTION}` + ).value + break + default: + mappingKey.assertUnexpectedValue(`${MAPPING_PROPERTY_VALUE} key`) // throws + } + } + } + } +} diff --git a/actions-workflow-parser/src/templates/schema/scalar-definition.ts b/actions-workflow-parser/src/templates/schema/scalar-definition.ts new file mode 100644 index 0000000..900ef71 --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/scalar-definition.ts @@ -0,0 +1,10 @@ +import { LiteralToken, MappingToken } from "../tokens" +import { Definition } from "./definition" + +export abstract class ScalarDefinition extends Definition { + public constructor(key: string, definition?: MappingToken) { + super(key, definition) + } + + public abstract isMatch(literal: LiteralToken): boolean +} diff --git a/actions-workflow-parser/src/templates/schema/sequence-definition.ts b/actions-workflow-parser/src/templates/schema/sequence-definition.ts new file mode 100644 index 0000000..59d2e2f --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/sequence-definition.ts @@ -0,0 +1,63 @@ +import { DEFINITION, SEQUENCE, ITEM_TYPE } from "../template-constants" +import { MappingToken } from "../tokens" +import { Definition } from "./definition" +import { DefinitionType } from "./definition-type" +import { TemplateSchema } from "./template-schema" + +export class SequenceDefinition extends Definition { + public itemType = "" + + public constructor(key: string, definition?: MappingToken) { + super(key, definition) + if (definition) { + for (const definitionPair of definition) { + const definitionKey = definitionPair.key.assertString( + `${DEFINITION} key` + ) + switch (definitionKey.value) { + case SEQUENCE: { + const mapping = definitionPair.value.assertMapping( + `${DEFINITION} ${SEQUENCE}` + ) + for (const mappingPair of mapping) { + const mappingKey = mappingPair.key.assertString( + `${DEFINITION} ${SEQUENCE} key` + ) + switch (mappingKey.value) { + case ITEM_TYPE: { + const itemType = mappingPair.value.assertString( + `${DEFINITION} ${SEQUENCE} ${ITEM_TYPE}` + ) + this.itemType = itemType.value + break + } + default: + // throws + mappingKey.assertUnexpectedValue( + `${DEFINITION} ${SEQUENCE} key` + ) + break + } + } + break + } + default: + definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws + } + } + } + } + + public override get definitionType(): DefinitionType { + return DefinitionType.Sequence + } + + public override validate(schema: TemplateSchema, name: string): void { + if (!this.itemType) { + throw new Error(`'${name}' does not defined '${ITEM_TYPE}'`) + } + + // Lookup item type + schema.getDefinition(this.itemType) + } +} diff --git a/actions-workflow-parser/src/templates/schema/string-definition.ts b/actions-workflow-parser/src/templates/schema/string-definition.ts new file mode 100644 index 0000000..0a80b1f --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/string-definition.ts @@ -0,0 +1,113 @@ +import { + CONSTANT, + DEFINITION, + IGNORE_CASE, + IS_EXPRESSION, + REQUIRE_NON_EMPTY, + STRING, +} from "../template-constants" +import { LiteralToken, MappingToken, StringToken } from "../tokens" +import { TokenType } from "../tokens/types" +import { DefinitionType } from "./definition-type" +import { ScalarDefinition } from "./scalar-definition" +import { TemplateSchema } from "./template-schema" + +export class StringDefinition extends ScalarDefinition { + public constant = "" + public ignoreCase = false + public requireNonEmpty = false + public isExpression = false + + public constructor(key: string, definition?: MappingToken) { + super(key, definition) + if (definition) { + for (const definitionPair of definition) { + const definitionKey = definitionPair.key.assertString( + `${DEFINITION} key` + ) + switch (definitionKey.value) { + case STRING: { + const mapping = definitionPair.value.assertMapping( + `${DEFINITION} ${STRING}` + ) + for (const mappingPair of mapping) { + const mappingKey = mappingPair.key.assertString( + `${DEFINITION} ${STRING} key` + ) + switch (mappingKey.value) { + case CONSTANT: { + const constantStringToken = mappingPair.value.assertString( + `${DEFINITION} ${STRING} ${CONSTANT}` + ) + this.constant = constantStringToken.value + break + } + case IGNORE_CASE: { + const ignoreCaseBooleanToken = + mappingPair.value.assertBoolean( + `${DEFINITION} ${STRING} ${IGNORE_CASE}` + ) + this.ignoreCase = ignoreCaseBooleanToken.value + break + } + case REQUIRE_NON_EMPTY: { + const requireNonEmptyBooleanToken = + mappingPair.value.assertBoolean( + `${DEFINITION} ${STRING} ${REQUIRE_NON_EMPTY}` + ) + this.requireNonEmpty = requireNonEmptyBooleanToken.value + break + } + case IS_EXPRESSION: { + const isExpressionToken = mappingPair.value.assertBoolean( + `${DEFINITION} ${STRING} ${IS_EXPRESSION}` + ) + this.isExpression = isExpressionToken.value + break + } + default: + // throws + mappingKey.assertUnexpectedValue( + `${DEFINITION} ${STRING} key` + ) + break + } + } + break + } + default: + definitionKey.assertUnexpectedValue(`${DEFINITION} key`) // throws + } + } + } + } + + public override get definitionType(): DefinitionType { + return DefinitionType.String + } + + public override isMatch(literal: LiteralToken): boolean { + if (literal.templateTokenType === TokenType.String) { + const value = (literal as StringToken).value + if (this.constant) { + return this.ignoreCase + ? this.constant.toUpperCase() === value.toUpperCase() + : this.constant === value + } else if (this.requireNonEmpty) { + return !!value + } else { + return true + } + } + + return false + } + + public override validate(schema: TemplateSchema, name: string): void { + if (this.constant && this.requireNonEmpty) { + throw new Error( + `Properties '${CONSTANT}' and '${REQUIRE_NON_EMPTY}' cannot both be set` + ) + } + } +} diff --git a/actions-workflow-parser/src/templates/schema/template-schema.ts b/actions-workflow-parser/src/templates/schema/template-schema.ts new file mode 100644 index 0000000..ad553df --- /dev/null +++ b/actions-workflow-parser/src/templates/schema/template-schema.ts @@ -0,0 +1,679 @@ +import { ObjectReader } from "../object-reader" +import { + ALLOWED_VALUES, + ANY, + BOOLEAN, + BOOLEAN_DEFINITION, + BOOLEAN_DEFINITION_PROPERTIES, + CONSTANT, + CONTEXT, + DEFINITION, + DEFINITIONS, + DESCRIPTION, + IGNORE_CASE, + IS_EXPRESSION, + ITEM_TYPE, + LOOSE_KEY_TYPE, + LOOSE_VALUE_TYPE, + MAPPING, + MAPPING_DEFINITION, + MAPPING_DEFINITION_PROPERTIES, + MAPPING_PROPERTY_VALUE, + NON_EMPTY_STRING, + NULL, + NULL_DEFINITION, + NULL_DEFINITION_PROPERTIES, + NUMBER, + NUMBER_DEFINITION, + NUMBER_DEFINITION_PROPERTIES, + ONE_OF, + ONE_OF_DEFINITION, + PROPERTIES, + PROPERTY_VALUE, + REQUIRED, + REQUIRE_NON_EMPTY, + SEQUENCE, + SEQUENCE_DEFINITION, + SEQUENCE_DEFINITION_PROPERTIES, + SEQUENCE_OF_NON_EMPTY_STRING, + STRING, + STRING_DEFINITION, + STRING_DEFINITION_PROPERTIES, + TEMPLATE_SCHEMA, + TYPE, + VERSION, +} from "../template-constants" +import { TemplateContext, TemplateValidationErrors } from "../template-context" +import { readTemplate } from "../template-reader" +import { MappingToken, SequenceToken, StringToken } from "../tokens" +import { TokenType } from "../tokens/types" +import { NoOperationTraceWriter } from "../trace-writer" +import { BooleanDefinition } from "./boolean-definition" +import { Definition } from "./definition" +import { DefinitionType } from "./definition-type" +import { MappingDefinition } from "./mapping-definition" +import { NullDefinition } from "./null-definition" +import { NumberDefinition } from "./number-definition" +import { OneOfDefinition } from "./one-of-definition" +import { PropertyDefinition } from "./property-definition" +import { ScalarDefinition } from "./scalar-definition" +import { SequenceDefinition } from "./sequence-definition" +import { StringDefinition } from "./string-definition" + +/** + * This models the root schema object and contains definitions + */ +export class TemplateSchema { + private static readonly _definitionNamePattern = /^[a-zA-Z_][a-zA-Z0-9_-]*$/ + private static _internalSchema: TemplateSchema | undefined + public readonly definitions: { [key: string]: Definition } = {} + public readonly version: string = "" + + public constructor(mapping?: MappingToken) { + // Add built-in type: null + this.definitions[NULL] = new NullDefinition(NULL) + + // Add built-in type: boolean + this.definitions[BOOLEAN] = new BooleanDefinition(BOOLEAN) + + // Add built-in type: number + this.definitions[NUMBER] = new NumberDefinition(NUMBER) + + // Add built-in type: string + this.definitions[STRING] = new StringDefinition(STRING) + + // Add built-in type: sequence + const sequenceDefinition = new SequenceDefinition(SEQUENCE) + sequenceDefinition.itemType = ANY + this.definitions[sequenceDefinition.key] = sequenceDefinition + + // Add built-in type: mapping + const mappingDefinition = new MappingDefinition(MAPPING) + mappingDefinition.looseKeyType = STRING + mappingDefinition.looseValueType = ANY + this.definitions[mappingDefinition.key] = mappingDefinition + + // Add built-in type: any + const anyDefinition = new OneOfDefinition(ANY) + anyDefinition.oneOf.push(NULL) + anyDefinition.oneOf.push(BOOLEAN) + anyDefinition.oneOf.push(NUMBER) + anyDefinition.oneOf.push(STRING) + anyDefinition.oneOf.push(SEQUENCE) + anyDefinition.oneOf.push(MAPPING) + this.definitions[anyDefinition.key] = anyDefinition + + if (mapping) { + for (const pair of mapping) { + const key = pair.key.assertString(`${TEMPLATE_SCHEMA} key`) + switch (key.value) { + case VERSION: { + this.version = pair.value.assertString( + `${TEMPLATE_SCHEMA} ${VERSION}` + ).value + break + } + case DEFINITIONS: { + const definitions = pair.value.assertMapping( + `${TEMPLATE_SCHEMA} ${DEFINITIONS}` + ) + for (const definitionsPair of definitions) { + const definitionsKey = definitionsPair.key.assertString( + `${TEMPLATE_SCHEMA} ${DEFINITIONS} key` + ) + const definitionsValue = definitionsPair.value.assertMapping( + `${TEMPLATE_SCHEMA} ${DEFINITIONS} value` + ) + let definition: Definition | undefined + for (const definitionPair of definitionsValue) { + const definitionKey = definitionPair.key.assertString( + `${DEFINITION} key` + ) + const mappingToken = definitionsPair.value as MappingToken + switch (definitionKey.value) { + case NULL: + definition = new NullDefinition( + definitionsKey.value, + definitionsValue + ) + break + case BOOLEAN: + definition = new BooleanDefinition( + definitionsKey.value, + definitionsValue + ) + break + case NUMBER: + definition = new NumberDefinition( + definitionsKey.value, + definitionsValue + ) + break + case STRING: + definition = new StringDefinition( + definitionsKey.value, + definitionsValue + ) + break + case SEQUENCE: + definition = new SequenceDefinition( + definitionsKey.value, + definitionsValue + ) + break + case MAPPING: + definition = new MappingDefinition( + definitionsKey.value, + definitionsValue + ) + break + case ONE_OF: + definition = new OneOfDefinition( + definitionsKey.value, + definitionsValue + ) + break + case ALLOWED_VALUES: + // Change the allowed-values definition into a one-of definition and its corresponding string definitions + for (const item of mappingToken) { + if (item.value.templateTokenType === TokenType.Sequence) { + // Create a new string definition for each StringToken in the sequence + const sequenceToken = item.value as SequenceToken + for (const activity of sequenceToken) { + if (activity.templateTokenType === TokenType.String) { + const stringToken = activity as StringToken + const allowedValuesKey = + definitionsKey.value + "-" + stringToken.value + const allowedValuesDef = new StringDefinition( + allowedValuesKey + ) + allowedValuesDef.constant = + stringToken.toDisplayString() + this.definitions[allowedValuesKey] = + allowedValuesDef + } + } + } + } + definition = new OneOfDefinition( + definitionsKey.value, + definitionsValue + ) + break + case CONTEXT: + case DESCRIPTION: + continue + default: + // throws + definitionKey.assertUnexpectedValue( + `${DEFINITION} mapping key` + ) + break + } + + break + } + + if (!definition) { + throw new Error( + `Not enough information to construct definition '${definitionsKey.value}'` + ) + } + + this.definitions[definitionsKey.value] = definition + } + break + } + default: + // throws + key.assertUnexpectedValue(`${TEMPLATE_SCHEMA} key`) + break + } + } + } + } + + /** + * Looks up a definition by name + */ + public getDefinition(name: string): Definition { + const result = this.definitions[name] + if (result) { + return result + } + + throw new Error(`Schema definition '${name}' not found`) + } + + /** + * Expands one-of definitions and returns all scalar definitions + */ + public getScalarDefinitions(definition: Definition): ScalarDefinition[] { + const result: ScalarDefinition[] = [] + switch (definition.definitionType) { + case DefinitionType.Null: + case DefinitionType.Boolean: + case DefinitionType.Number: + case DefinitionType.String: + result.push(definition as ScalarDefinition) + break + case DefinitionType.OneOf: { + const oneOf = definition as OneOfDefinition + + // Expand nested one-of definitions + for (const nestedName of oneOf.oneOf) { + const nestedDefinition = this.getDefinition(nestedName) + result.push(...this.getScalarDefinitions(nestedDefinition)) + } + break + } + } + + return result + } + + /** + * Expands one-of definitions and returns all matching definitions by type + */ + public getDefinitionsOfType( + definition: Definition, + type: DefinitionType + ): Definition[] { + const result: Definition[] = [] + if (definition.definitionType === type) { + result.push(definition) + } else if (definition.definitionType === DefinitionType.OneOf) { + const oneOf = definition as OneOfDefinition + for (const nestedName of oneOf.oneOf) { + const nestedDefinition = this.getDefinition(nestedName) + if (nestedDefinition.definitionType === type) { + result.push(nestedDefinition) + } + } + } + + return result + } + + /** + * Attempts match the property name to a property defined by any of the specified definitions. + * If matched, any unmatching definitions are filtered from the definitions array. + * Returns the type information for the matched property. + */ + public matchPropertyAndFilter( + definitions: MappingDefinition[], + propertyName: string + ): PropertyDefinition | undefined { + let result: PropertyDefinition | undefined + + // Check for a matching well-known property + let notFoundInSome = false + for (const definition of definitions) { + const propertyDef = definition.properties[propertyName] + if (propertyDef) { + result = propertyDef + } else { + notFoundInSome = true + } + } + + // Filter the matched definitions if needed + if (result && notFoundInSome) { + for (let i = 0; i < definitions.length; ) { + if (definitions[i].properties[propertyName]) { + i++ + } else { + definitions.splice(i, 1) + } + } + } + + return result + } + + private validate(): void { + const oneOfDefinitions: { [key: string]: OneOfDefinition } = {} + + for (const name of Object.keys(this.definitions)) { + if (!name.match(TemplateSchema._definitionNamePattern)) { + throw new Error(`Invalid definition name '${name}'`) + } + + const definition = this.definitions[name] + + // Delay validation for 'one-of' definitions + if (definition.definitionType === DefinitionType.OneOf) { + oneOfDefinitions[name] = definition as OneOfDefinition + } + // Otherwise validate now + else { + definition.validate(this, name) + } + } + + // Validate 'one-of' definitions + for (const name of Object.keys(oneOfDefinitions)) { + const oneOf = oneOfDefinitions[name] + oneOf.validate(this, name) + } + } + + /** + * Loads a user-defined schema file + */ + public static load(objectReader: ObjectReader): TemplateSchema { + const context = new TemplateContext( + new TemplateValidationErrors(10, 500), + TemplateSchema.getInternalSchema(), + new NoOperationTraceWriter() + ) + const template = readTemplate( + context, + TEMPLATE_SCHEMA, + objectReader, + undefined + ) + context.errors.check() + + const mapping = template!.assertMapping(TEMPLATE_SCHEMA) + const schema = new TemplateSchema(mapping) + schema.validate() + return schema + } + + /** + * Gets the internal schema used for reading user-defined schema files + */ + private static getInternalSchema(): TemplateSchema { + if (TemplateSchema._internalSchema === undefined) { + const schema = new TemplateSchema() + + // template-schema + let mappingDefinition = new MappingDefinition(TEMPLATE_SCHEMA) + mappingDefinition.properties[VERSION] = new PropertyDefinition( + new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined) + ) + mappingDefinition.properties[DEFINITIONS] = new PropertyDefinition( + new StringToken(undefined, undefined, DEFINITIONS, undefined) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // definitions + mappingDefinition = new MappingDefinition(DEFINITIONS) + mappingDefinition.looseKeyType = NON_EMPTY_STRING + mappingDefinition.looseValueType = DEFINITION + schema.definitions[mappingDefinition.key] = mappingDefinition + + // definition + let oneOfDefinition = new OneOfDefinition(DEFINITION) + oneOfDefinition.oneOf.push(NULL_DEFINITION) + oneOfDefinition.oneOf.push(BOOLEAN_DEFINITION) + oneOfDefinition.oneOf.push(NUMBER_DEFINITION) + oneOfDefinition.oneOf.push(STRING_DEFINITION) + oneOfDefinition.oneOf.push(SEQUENCE_DEFINITION) + oneOfDefinition.oneOf.push(MAPPING_DEFINITION) + oneOfDefinition.oneOf.push(ONE_OF_DEFINITION) + schema.definitions[oneOfDefinition.key] = oneOfDefinition + + // null-definition + mappingDefinition = new MappingDefinition(NULL_DEFINITION) + mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition( + new StringToken(undefined, undefined, STRING, undefined) + ) + mappingDefinition.properties[CONTEXT] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + SEQUENCE_OF_NON_EMPTY_STRING, + undefined + ) + ) + mappingDefinition.properties[NULL] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + NULL_DEFINITION_PROPERTIES, + undefined + ) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // null-definition-properties + mappingDefinition = new MappingDefinition(NULL_DEFINITION_PROPERTIES) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // boolean-definition + mappingDefinition = new MappingDefinition(BOOLEAN_DEFINITION) + mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition( + new StringToken(undefined, undefined, STRING, undefined) + ) + mappingDefinition.properties[CONTEXT] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + SEQUENCE_OF_NON_EMPTY_STRING, + undefined + ) + ) + mappingDefinition.properties[BOOLEAN] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + BOOLEAN_DEFINITION_PROPERTIES, + undefined + ) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // boolean-definition-properties + mappingDefinition = new MappingDefinition(BOOLEAN_DEFINITION_PROPERTIES) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // number-definition + mappingDefinition = new MappingDefinition(NUMBER_DEFINITION) + mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition( + new StringToken(undefined, undefined, STRING, undefined) + ) + mappingDefinition.properties[CONTEXT] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + SEQUENCE_OF_NON_EMPTY_STRING, + undefined + ) + ) + mappingDefinition.properties[NUMBER] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + NUMBER_DEFINITION_PROPERTIES, + undefined + ) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // number-definition-properties + mappingDefinition = new MappingDefinition(NUMBER_DEFINITION_PROPERTIES) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // string-definition + mappingDefinition = new MappingDefinition(STRING_DEFINITION) + mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition( + new StringToken(undefined, undefined, STRING, undefined) + ) + mappingDefinition.properties[CONTEXT] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + SEQUENCE_OF_NON_EMPTY_STRING, + undefined + ) + ) + mappingDefinition.properties[STRING] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + STRING_DEFINITION_PROPERTIES, + undefined + ) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // string-definition-properties + mappingDefinition = new MappingDefinition(STRING_DEFINITION_PROPERTIES) + mappingDefinition.properties[CONSTANT] = new PropertyDefinition( + new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined) + ) + mappingDefinition.properties[IGNORE_CASE] = new PropertyDefinition( + new StringToken(undefined, undefined, BOOLEAN, undefined) + ) + mappingDefinition.properties[REQUIRE_NON_EMPTY] = new PropertyDefinition( + new StringToken(undefined, undefined, BOOLEAN, undefined) + ) + mappingDefinition.properties[IS_EXPRESSION] = new PropertyDefinition( + new StringToken(undefined, undefined, BOOLEAN, undefined) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // sequence-definition + mappingDefinition = new MappingDefinition(SEQUENCE_DEFINITION) + mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition( + new StringToken(undefined, undefined, STRING, undefined) + ) + mappingDefinition.properties[CONTEXT] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + SEQUENCE_OF_NON_EMPTY_STRING, + undefined + ) + ) + mappingDefinition.properties[SEQUENCE] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + SEQUENCE_DEFINITION_PROPERTIES, + undefined + ) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // sequence-definition-properties + mappingDefinition = new MappingDefinition(SEQUENCE_DEFINITION_PROPERTIES) + mappingDefinition.properties[ITEM_TYPE] = new PropertyDefinition( + new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // mapping-definition + mappingDefinition = new MappingDefinition(MAPPING_DEFINITION) + mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition( + new StringToken(undefined, undefined, STRING, undefined) + ) + mappingDefinition.properties[CONTEXT] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + SEQUENCE_OF_NON_EMPTY_STRING, + undefined + ) + ) + mappingDefinition.properties[MAPPING] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + MAPPING_DEFINITION_PROPERTIES, + undefined + ) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // mapping-definition-properties + mappingDefinition = new MappingDefinition(MAPPING_DEFINITION_PROPERTIES) + mappingDefinition.properties[PROPERTIES] = new PropertyDefinition( + new StringToken(undefined, undefined, PROPERTIES, undefined) + ) + mappingDefinition.properties[LOOSE_KEY_TYPE] = new PropertyDefinition( + new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined) + ) + mappingDefinition.properties[LOOSE_VALUE_TYPE] = new PropertyDefinition( + new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // properties + mappingDefinition = new MappingDefinition(PROPERTIES) + mappingDefinition.looseKeyType = NON_EMPTY_STRING + mappingDefinition.looseValueType = PROPERTY_VALUE + schema.definitions[mappingDefinition.key] = mappingDefinition + + // property-value + oneOfDefinition = new OneOfDefinition(PROPERTY_VALUE) + oneOfDefinition.oneOf.push(NON_EMPTY_STRING) + oneOfDefinition.oneOf.push(MAPPING_PROPERTY_VALUE) + schema.definitions[oneOfDefinition.key] = oneOfDefinition + + // mapping-property-value + mappingDefinition = new MappingDefinition(MAPPING_PROPERTY_VALUE) + mappingDefinition.properties[TYPE] = new PropertyDefinition( + new StringToken(undefined, undefined, NON_EMPTY_STRING, undefined) + ) + mappingDefinition.properties[REQUIRED] = new PropertyDefinition( + new StringToken(undefined, undefined, BOOLEAN, undefined) + ) + mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition( + new StringToken(undefined, undefined, STRING, undefined) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // one-of-definition + mappingDefinition = new MappingDefinition(ONE_OF_DEFINITION) + mappingDefinition.properties[DESCRIPTION] = new PropertyDefinition( + new StringToken(undefined, undefined, STRING, undefined) + ) + mappingDefinition.properties[CONTEXT] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + SEQUENCE_OF_NON_EMPTY_STRING, + undefined + ) + ) + mappingDefinition.properties[ONE_OF] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + SEQUENCE_OF_NON_EMPTY_STRING, + undefined + ) + ) + mappingDefinition.properties[ALLOWED_VALUES] = new PropertyDefinition( + new StringToken( + undefined, + undefined, + SEQUENCE_OF_NON_EMPTY_STRING, + undefined + ) + ) + schema.definitions[mappingDefinition.key] = mappingDefinition + + // non-empty-string + const stringDefinition = new StringDefinition(NON_EMPTY_STRING) + stringDefinition.requireNonEmpty = true + schema.definitions[stringDefinition.key] = stringDefinition + + // sequence-of-non-empty-string + const sequenceDefinition = new SequenceDefinition( + SEQUENCE_OF_NON_EMPTY_STRING + ) + sequenceDefinition.itemType = NON_EMPTY_STRING + schema.definitions[sequenceDefinition.key] = sequenceDefinition + + schema.validate() + + TemplateSchema._internalSchema = schema + } + + return TemplateSchema._internalSchema + } +} diff --git a/actions-workflow-parser/src/templates/template-constants.ts b/actions-workflow-parser/src/templates/template-constants.ts new file mode 100644 index 0000000..7162050 --- /dev/null +++ b/actions-workflow-parser/src/templates/template-constants.ts @@ -0,0 +1,48 @@ +export const ALLOWED_VALUES = "allowed-values" +export const ANY = "any" +export const BOOLEAN = "boolean" +export const BOOLEAN_DEFINITION = "boolean-definition" +export const BOOLEAN_DEFINITION_PROPERTIES = "boolean-definition-properties" +export const CLOSE_EXPRESSION = "}}" +export const CONSTANT = "constant" +export const CONTEXT = "context" +export const DEFINITION = "definition" +export const DEFINITIONS = "definitions" +export const DESCRIPTION = "description" +export const IGNORE_CASE = "ignore-case" +export const INSERT_DIRECTIVE = "insert" +export const IS_EXPRESSION = "is-expression" +export const ITEM_TYPE = "item-type" +export const LOOSE_KEY_TYPE = "loose-key-type" +export const LOOSE_VALUE_TYPE = "loose-value-type" +export const MAX_CONSTANT = "MAX" +export const MAPPING = "mapping" +export const MAPPING_DEFINITION = "mapping-definition" +export const MAPPING_DEFINITION_PROPERTIES = "mapping-definition-properties" +export const MAPPING_PROPERTY_VALUE = "mapping-property-value" +export const NON_EMPTY_STRING = "non-empty-string" +export const NULL = "null" +export const NULL_DEFINITION = "null-definition" +export const NULL_DEFINITION_PROPERTIES = "null-definition-properties" +export const NUMBER = "number" +export const NUMBER_DEFINITION = "number-definition" +export const NUMBER_DEFINITION_PROPERTIES = "number-definition-properties" +export const ONE_OF = "one-of" +export const ONE_OF_DEFINITION = "one-of-definition" +export const OPEN_EXPRESSION = "${{" +export const PROPERTY_VALUE = "property-value" +export const PROPERTIES = "properties" +export const REQUIRED = "required" +export const REQUIRE_NON_EMPTY = "require-non-empty" +export const SCALAR = "scalar" +export const SEQUENCE = "sequence" +export const SEQUENCE_DEFINITION = "sequence-definition" +export const SEQUENCE_DEFINITION_PROPERTIES = "sequence-definition-properties" +export const TYPE = "type" +export const SEQUENCE_OF_NON_EMPTY_STRING = "sequence-of-non-empty-string" +export const STRING = "string" +export const STRING_DEFINITION = "string-definition" +export const STRING_DEFINITION_PROPERTIES = "string-definition-properties" +export const STRUCTURE = "structure" +export const TEMPLATE_SCHEMA = "template-schema" +export const VERSION = "version" diff --git a/actions-workflow-parser/src/templates/template-context.ts b/actions-workflow-parser/src/templates/template-context.ts new file mode 100644 index 0000000..dda7786 --- /dev/null +++ b/actions-workflow-parser/src/templates/template-context.ts @@ -0,0 +1,199 @@ +import { FunctionInfo } from "@github/actions-expressions/funcs/info" + +import { TemplateSchema } from "./schema/template-schema" +import { TemplateValidationError } from "./template-validation-error" +import { TemplateToken } from "./tokens" +import { TokenRange } from "./tokens/token-range" +import { TraceWriter } from "./trace-writer" +/** + * Context object that is flowed through while loading and evaluating object templates + */ +export class TemplateContext { + private readonly _fileIds: { [name: string]: number } = {} + private readonly _fileNames: string[] = [] + + /** + * Available functions within expression contexts + */ + public readonly expressionFunctions: FunctionInfo[] = [] + + /** + * Available values within expression contexts + */ + public readonly expressionNamedContexts: string[] = [] + + public readonly errors: TemplateValidationErrors + public readonly schema: TemplateSchema + public readonly trace: TraceWriter + public readonly state: { [key: string]: any } = {} + + public constructor( + errors: TemplateValidationErrors, + schema: TemplateSchema, + trace: TraceWriter + ) { + this.errors = errors + this.schema = schema + this.trace = trace + } + + public error( + token: TemplateToken | undefined, + err: string, + tokenRange?: TokenRange + ): void + public error( + token: TemplateToken | undefined, + err: Error, + tokenRange?: TokenRange + ): void + public error(token: TemplateToken | undefined, err: unknown): void + public error( + fileId: number | undefined, + err: string, + tokenRange?: TokenRange + ): void + public error( + fileId: number | undefined, + err: Error, + tokenRange?: TokenRange + ): void + public error( + fileId: number | undefined, + err: unknown, + tokenRange?: TokenRange + ): void + public error( + tokenOrFileId: TemplateToken | number | undefined, + err: string | Error | unknown, + tokenRange?: TokenRange + ): void { + const token = tokenOrFileId as TemplateToken | undefined + const range = tokenRange || token?.range + const prefix = this.getErrorPrefix( + token?.file ?? (tokenOrFileId as number | undefined), + token?.line, + token?.col + ) + const message = (err as Error | undefined)?.message ?? `${err}` + + const e = new TemplateValidationError(message, prefix, undefined, range) + this.errors.add(e) + this.trace.error(e.message) + } + + /** + * Gets or adds the file ID + */ + public getFileId(file: string) { + const key = file.toUpperCase() + let id: number | undefined = this._fileIds[key] + if (id === undefined) { + id = this._fileNames.length + 1 + this._fileIds[key] = id + this._fileNames.push(file) + } + + return id + } + + /** + * Looks up a file name by ID. Returns undefined if not found. + */ + public getFileName(fileId: number): string | undefined { + return this._fileNames.length >= fileId + ? this._fileNames[fileId - 1] + : undefined + } + + /** + * Gets a copy of the file table + */ + public getFileTable(): string[] { + return this._fileNames.slice() + } + + private getErrorPrefix( + fileId?: number, + line?: number, + column?: number + ): string { + const fileName = + fileId !== undefined ? this.getFileName(fileId as number) : undefined + if (fileName) { + if (line !== undefined && column !== undefined) { + return `${fileName} (Line: ${line}, Col: ${column})` + } else { + return fileName + } + } else if (line !== undefined && column !== undefined) { + return `(Line: ${line}, Col: ${column})` + } else { + return "" + } + } +} + +/** + * Provides information about errors which occurred during validation + */ +export class TemplateValidationErrors { + private readonly _maxErrors: number + private readonly _maxMessageLength: number + private _errors: TemplateValidationError[] = [] + + public constructor(maxErrors?: number, maxMessageLength?: number) { + this._maxErrors = maxErrors ?? 0 + this._maxMessageLength = maxMessageLength ?? 0 + } + + public get count(): number { + return this._errors.length + } + + public add(err: TemplateValidationError | TemplateValidationError[]): void { + for (let e of Array.isArray(err) ? err : [err]) { + // Check max errors + if (this._maxErrors <= 0 || this._errors.length < this._maxErrors) { + // Check max message length + if ( + this._maxMessageLength > 0 && + e.message.length > this._maxMessageLength + ) { + e = new TemplateValidationError( + e.message.substring(0, this._maxMessageLength) + "[...]", + e.prefix, + e.code, + e.range + ) + } + + this._errors.push(e) + } + } + } + + /** + * Throws if any errors + * @param prefix The error message prefix + */ + public check(prefix?: string): void { + if (this._errors.length <= 0) { + return + } + + if (!prefix) { + prefix = "The template is not valid." + } + + throw new Error(`${prefix} ${this._errors.map((x) => x.message).join(",")}`) + } + + public clear(): void { + this._errors = [] + } + + public getErrors(): TemplateValidationError[] { + return this._errors.slice() + } +} diff --git a/actions-workflow-parser/src/templates/template-evaluator.ts b/actions-workflow-parser/src/templates/template-evaluator.ts new file mode 100644 index 0000000..717c98d --- /dev/null +++ b/actions-workflow-parser/src/templates/template-evaluator.ts @@ -0,0 +1,450 @@ +// called at runtime. expands expressions and re-validates the schema result after expansion + +import { TemplateSchema } from "./schema" +import { Definition } from "./schema/definition" +import { DefinitionType } from "./schema/definition-type" +import { MappingDefinition } from "./schema/mapping-definition" +import { ScalarDefinition } from "./schema/scalar-definition" +import { SequenceDefinition } from "./schema/sequence-definition" +import { ANY } from "./template-constants" +import { TemplateContext } from "./template-context" +import { TemplateUnraveler } from "./template-unraveler" +import { + LiteralToken, + MappingToken, + ScalarToken, + StringToken, + TemplateToken, +} from "./tokens" +import { TokenType } from "./tokens/types" + +export function evaluateTemplate( + context: TemplateContext, + type: string, + template: TemplateToken, + removeBytes: number, + fileId: number | undefined +): TemplateToken | undefined { + let result: TemplateToken | undefined + + const evaluator = new TemplateEvaluator(context, template, removeBytes) + try { + const definitionInfo = new DefinitionInfo(context, type) + result = evaluator.evaluate(definitionInfo) + + if (result) { + evaluator.unraveler.readEnd() + } + } catch (err) { + context.error(fileId, err) + result = undefined + } + + return result +} + +class TemplateEvaluator { + public readonly context: TemplateContext + public readonly schema: TemplateSchema + public readonly unraveler: TemplateUnraveler + + public constructor( + context: TemplateContext, + template: TemplateToken, + removeBytes: number + ) { + this.context = context + this.schema = context.schema + this.unraveler = new TemplateUnraveler(context, template, removeBytes) + } + + public evaluate(definition: DefinitionInfo): TemplateToken { + // Scalar + const scalar = this.unraveler.allowScalar(definition.expand) + if (scalar) { + if (scalar.isLiteral) { + return this.validate(scalar as LiteralToken, definition) + } else { + return scalar + } + } + + // Sequence + const sequence = this.unraveler.allowSequenceStart(definition.expand) + if (sequence) { + const sequenceDefinition = definition.getDefinitionsOfType( + DefinitionType.Sequence + )[0] as SequenceDefinition | undefined + + // Legal + if (sequenceDefinition) { + const itemDefinition = new DefinitionInfo( + definition, + sequenceDefinition.itemType + ) + + // Add each item + while (!this.unraveler.allowSequenceEnd(definition.expand)) { + const item = this.evaluate(itemDefinition) + sequence.add(item) + } + } + // Illegal + else { + // Error + this.context.error(sequence, "A sequence was not expected") + + // Skip each item + while (!this.unraveler.allowSequenceEnd(false)) { + this.unraveler.skipSequenceItem() + } + } + + return sequence + } + + // Mapping + const mapping = this.unraveler.allowMappingStart(definition.expand) + if (mapping) { + const mappingDefinitions = definition.getDefinitionsOfType( + DefinitionType.Mapping + ) as MappingDefinition[] + + // Legal + if (mappingDefinitions.length > 0) { + if ( + mappingDefinitions.length > 1 || + Object.keys(mappingDefinitions[0].properties).length > 0 || + !mappingDefinitions[0].looseKeyType + ) { + this.handleMappingWithWellKnownProperties( + definition, + mappingDefinitions, + mapping + ) + } else { + const keyDefinition = new DefinitionInfo( + definition, + mappingDefinitions[0].looseKeyType + ) + const valueDefinition = new DefinitionInfo( + definition, + mappingDefinitions[0].looseValueType + ) + this.handleMappingWithAllLooseProperties( + definition, + keyDefinition, + valueDefinition, + mapping + ) + } + } + // Illegal + else { + this.context.error(mapping, "A mapping was not expected") + + while (!this.unraveler.allowMappingEnd(false)) { + this.unraveler.skipMappingKey() + this.unraveler.skipMappingValue() + } + } + + return mapping + } + + throw new Error("Expected a scalar value, a sequence, or a mapping") + } + + private handleMappingWithWellKnownProperties( + definition: DefinitionInfo, + mappingDefinitions: MappingDefinition[], + mapping: MappingToken + ) { + // Check if loose properties are allowed + let looseKeyType: string | undefined + let looseValueType: string | undefined + let looseKeyDefinition: DefinitionInfo | undefined + let looseValueDefinition: DefinitionInfo | undefined + if (mappingDefinitions[0].looseKeyType) { + looseKeyType = mappingDefinitions[0].looseKeyType + looseValueType = mappingDefinitions[0].looseValueType + } + + const upperKeys: { [upperKey: string]: boolean } = {} + let hasExpressionKey = false + + let nextKeyScalar: ScalarToken | undefined + while ((nextKeyScalar = this.unraveler.allowScalar(definition.expand))) { + // Expression + if (nextKeyScalar.isExpression) { + hasExpressionKey = true + const anyDefinition = new DefinitionInfo(definition, ANY) + mapping.add(nextKeyScalar, this.evaluate(anyDefinition)) + continue + } + + // Convert to StringToken if required + const nextKey = + nextKeyScalar.templateTokenType === TokenType.String + ? (nextKeyScalar as StringToken) + : new StringToken( + nextKeyScalar.file, + nextKeyScalar.range, + nextKeyScalar.toString(), + nextKeyScalar.definitionInfo + ) + + // Duplicate + const upperKey = nextKey.value.toUpperCase() + if (upperKeys[upperKey]) { + this.context.error(nextKey, `'${nextKey.value}' is already defined`) + this.unraveler.skipMappingValue() + continue + } + upperKeys[upperKey] = true + + // Well known + const nextValuePropertyDef = this.schema.matchPropertyAndFilter( + mappingDefinitions, + nextKey.value + ) + if (nextValuePropertyDef?.type) { + const nextValueDefinition = new DefinitionInfo( + definition, + nextValuePropertyDef.type + ) + const nextValue = this.evaluate(nextValueDefinition) + nextValue.propertyDefinition = nextValuePropertyDef + mapping.add(nextKey, nextValue) + continue + } + + // Loose + if (looseKeyType) { + if (!looseKeyDefinition) { + looseKeyDefinition = new DefinitionInfo(definition, looseKeyType) + looseValueDefinition = new DefinitionInfo(definition, looseValueType!) + } + + this.validate(nextKey, looseKeyDefinition) + const nextValue = this.evaluate(looseValueDefinition!) + mapping.add(nextKey, nextValue) + continue + } + + // Error + this.context.error(nextKey, `Unexpected value '${nextKey.value}'`) + this.unraveler.skipMappingValue() + } + + // Unable to filter to one definition + if (mappingDefinitions.length > 1) { + const hitCount: { [key: string]: number } = {} + for (const mappingDefinition of mappingDefinitions) { + for (const key of Object.keys(mappingDefinition.properties)) { + hitCount[key] = (hitCount[key] ?? 0) + 1 + } + } + + const nonDuplicates: string[] = [] + for (const key of Object.keys(hitCount)) { + if (hitCount[key] === 1) { + nonDuplicates.push(key) + } + } + + this.context.error( + mapping, + `There's not enough info to determine what you meant. Add one of these properties: ${nonDuplicates + .sort() + .join(", ")}` + ) + } + // Check required properties + else if (mappingDefinitions.length === 1 && !hasExpressionKey) { + for (const propertyName of Object.keys( + mappingDefinitions[0].properties + )) { + const propertyDef = mappingDefinitions[0].properties[propertyName] + if (propertyDef.required && !upperKeys[propertyName.toUpperCase()]) { + this.context.error( + mapping, + `Required property is missing: ${propertyName}` + ) + } + } + } + + this.unraveler.readMappingEnd() + } + + private handleMappingWithAllLooseProperties( + mappingDefinition: DefinitionInfo, + keyDefinition: DefinitionInfo, + valueDefinition: DefinitionInfo, + mapping: MappingToken + ): void { + const upperKeys: { [key: string]: boolean } = {} + + let nextKeyScalar: ScalarToken | undefined + while ( + (nextKeyScalar = this.unraveler.allowScalar(mappingDefinition.expand)) + ) { + // Expression + if (nextKeyScalar.isExpression) { + if (nextKeyScalar.templateTokenType === TokenType.BasicExpression) { + mapping.add(nextKeyScalar, this.evaluate(valueDefinition)) + } else { + const anyDefinition = new DefinitionInfo(mappingDefinition, ANY) + mapping.add(nextKeyScalar, this.evaluate(anyDefinition)) + } + + continue + } + + // Convert to StringToken if required + const nextKey = + nextKeyScalar.templateTokenType === TokenType.String + ? (nextKeyScalar as StringToken) + : new StringToken( + nextKeyScalar.file, + nextKeyScalar.range, + nextKeyScalar.toString(), + nextKeyScalar.definitionInfo + ) + + // Duplicate + const upperKey = nextKey.value.toUpperCase() + if (upperKeys[upperKey]) { + this.context.error(nextKey, `'${nextKey.value}' is already defined`) + this.unraveler.skipMappingValue() + continue + } + upperKeys[upperKey] = true + + // Validate + this.validate(nextKey, keyDefinition) + + // Add the pair + const nextValue = this.evaluate(valueDefinition) + mapping.add(nextKey, nextValue) + } + + this.unraveler.readMappingEnd() + } + + private validate( + literal: LiteralToken, + definition: DefinitionInfo + ): LiteralToken { + // Legal + const scalarDefinitions = definition.getScalarDefinitions() + if (scalarDefinitions.some((x) => x.isMatch(literal))) { + return literal + } + + // Not a string, convert + if (literal.templateTokenType !== TokenType.String) { + const stringLiteral = new StringToken( + literal.file, + literal.range, + literal.toString(), + literal.definitionInfo + ) + + // Legal + if (scalarDefinitions.some((x) => x.isMatch(stringLiteral))) { + return stringLiteral + } + } + + // Illegal + this.context.error(literal, `Unexpected value '${literal.toString()}'`) + return literal + } +} + +class DefinitionInfo { + /** + * Hashtable of available contexts + */ + private readonly _upperAvailable: { [context: string]: boolean } + /** + * Allowed context + */ + private readonly _allowed: string[] + private readonly _schema: TemplateSchema + public readonly isDefinitionInfo = true + public readonly definition: Definition + public readonly expand: boolean + + public constructor(context: TemplateContext, name: string) + public constructor(parent: DefinitionInfo, name: string) + public constructor( + contextOrParent: TemplateContext | DefinitionInfo, + name: string + ) { + // "parent" overload + let parent: DefinitionInfo | undefined + if ( + (contextOrParent as DefinitionInfo | undefined)?.isDefinitionInfo === true + ) { + parent = contextOrParent as DefinitionInfo + this._schema = parent._schema + this._upperAvailable = parent._upperAvailable + } + // "context" overload + else { + const context = contextOrParent as TemplateContext + this._schema = context.schema + this._upperAvailable = {} + for (const namedContext of context.expressionNamedContexts) { + this._upperAvailable[namedContext.toUpperCase()] = true + } + for (const func of context.expressionFunctions) { + this._upperAvailable[`${func.name}()`.toUpperCase()] = true + } + } + + // Lookup the definition + this.definition = this._schema.getDefinition(name) + + // Record allowed context + if (this.definition.evaluatorContext.length > 0) { + this._allowed = [] + this.expand = true + + // Copy parent allowed context + const upperSeen: { [upper: string]: boolean } = {} + for (const context of parent?._allowed ?? []) { + this._allowed.push(context) + const upper = context.toUpperCase() + upperSeen[upper] = true + if (!this._upperAvailable[upper]) { + this.expand = false + } + } + + // Append context if unseen + for (const context of this.definition.evaluatorContext) { + const upper = context.toUpperCase() + if (!upperSeen[upper]) { + this._allowed.push(context) + upperSeen[upper] = true + if (!this._upperAvailable[upper]) [(this.expand = false)] + } + } + } else { + this._allowed = parent?._allowed ?? [] + this.expand = parent?.expand ?? false + } + } + + public getScalarDefinitions(): ScalarDefinition[] { + return this._schema.getScalarDefinitions(this.definition) + } + + public getDefinitionsOfType(type: DefinitionType): Definition[] { + return this._schema.getDefinitionsOfType(this.definition, type) + } +} diff --git a/actions-workflow-parser/src/templates/template-reader.ts b/actions-workflow-parser/src/templates/template-reader.ts new file mode 100644 index 0000000..0c5e26b --- /dev/null +++ b/actions-workflow-parser/src/templates/template-reader.ts @@ -0,0 +1,919 @@ +// template-reader *just* does schema validation + +import { ObjectReader } from "./object-reader" +import { TemplateSchema } from "./schema" +import { DefinitionInfo } from "./schema/definition-info" +import { DefinitionType } from "./schema/definition-type" +import { MappingDefinition } from "./schema/mapping-definition" +import { ScalarDefinition } from "./schema/scalar-definition" +import { SequenceDefinition } from "./schema/sequence-definition" +import { StringDefinition } from "./schema/string-definition" +import { + ANY, + CLOSE_EXPRESSION, + INSERT_DIRECTIVE, + OPEN_EXPRESSION, +} from "./template-constants" +import { TemplateContext } from "./template-context" +import { + BasicExpressionToken, + ExpressionToken, + InsertExpressionToken, + LiteralToken, + MappingToken, + ScalarToken, + StringToken, + TemplateToken, +} from "./tokens" +import { TokenRange } from "./tokens/token-range" +import { isString } from "./tokens/type-guards" +import { TokenType } from "./tokens/types" + +const WHITESPACE_PATTERN = /\s/ + +export function readTemplate( + context: TemplateContext, + type: string, + objectReader: ObjectReader, + fileId: number | undefined +): TemplateToken | undefined { + const reader = new TemplateReader(context, objectReader, fileId) + let value: TemplateToken | undefined + try { + objectReader.validateStart() + const definition = new DefinitionInfo(context.schema, type) + value = reader.readValue(definition) + objectReader.validateEnd() + } catch (err) { + context.error(fileId, err) + } + + return value +} + +export interface ReadTemplateResult { + value: TemplateToken + bytes: number +} + +class TemplateReader { + private readonly _context: TemplateContext + private readonly _schema: TemplateSchema + private readonly _objectReader: ObjectReader + private readonly _fileId: number | undefined + + public constructor( + context: TemplateContext, + objectReader: ObjectReader, + fileId: number | undefined + ) { + this._context = context + this._schema = context.schema + this._objectReader = objectReader + this._fileId = fileId + } + + public readValue(definition: DefinitionInfo): TemplateToken { + // Scalar + const literal = this._objectReader.allowLiteral() + if (literal) { + let scalar = this.parseScalar(literal, definition) + scalar = this.validate(scalar, definition) + return scalar + } + + // Sequence + const sequence = this._objectReader.allowSequenceStart() + if (sequence) { + const sequenceDefinition = definition.getDefinitionsOfType( + DefinitionType.Sequence + )[0] as SequenceDefinition | undefined + + // Legal + if (sequenceDefinition) { + const itemDefinition = new DefinitionInfo( + definition, + sequenceDefinition.itemType + ) + + // Add each item + while (!this._objectReader.allowSequenceEnd()) { + const item = this.readValue(itemDefinition) + sequence.add(item) + } + } + // Illegal + else { + // Error + this._context.error(sequence, "A sequence was not expected") + + // Skip each item + while (!this._objectReader.allowSequenceEnd()) { + this.skipValue() + } + } + sequence.definitionInfo = definition + return sequence + } + + // Mapping + const mapping = this._objectReader.allowMappingStart() + if (mapping) { + const mappingDefinitions = definition.getDefinitionsOfType( + DefinitionType.Mapping + ) as MappingDefinition[] + + // Legal + if (mappingDefinitions.length > 0) { + if ( + mappingDefinitions.length > 1 || + Object.keys(mappingDefinitions[0].properties).length > 0 || + !mappingDefinitions[0].looseKeyType + ) { + this.handleMappingWithWellKnownProperties( + definition, + mappingDefinitions, + mapping + ) + } else { + const keyDefinition = new DefinitionInfo( + definition, + mappingDefinitions[0].looseKeyType + ) + const valueDefinition = new DefinitionInfo( + definition, + mappingDefinitions[0].looseValueType + ) + this.handleMappingWithAllLooseProperties( + definition, + keyDefinition, + valueDefinition, + mappingDefinitions[0], + mapping + ) + } + } + // Illegal + else { + this._context.error(mapping, "A mapping was not expected") + + while (this._objectReader.allowMappingEnd()) { + this.skipValue() + this.skipValue() + } + } + + // handleMappingWithWellKnownProperties will only set a definition + // if it can identify a single matching definition + if (!mapping.definitionInfo) { + mapping.definitionInfo = definition + } + + return mapping + } + + throw new Error("Expected a scalar value, a sequence, or a mapping") + } + + private handleMappingWithWellKnownProperties( + definition: DefinitionInfo, + mappingDefinitions: MappingDefinition[], + mapping: MappingToken + ): void { + // Check if loose properties are allowed + let looseKeyType: string | undefined + let looseValueType: string | undefined + let looseKeyDefinition: DefinitionInfo | undefined + let looseValueDefinition: DefinitionInfo | undefined + if (mappingDefinitions[0].looseKeyType) { + looseKeyType = mappingDefinitions[0].looseKeyType + looseValueType = mappingDefinitions[0].looseValueType + } + + const upperKeys: { [upperKey: string]: boolean } = {} + let hasExpressionKey = false + + let rawLiteral: LiteralToken | undefined + while ((rawLiteral = this._objectReader.allowLiteral())) { + const nextKeyScalar = this.parseScalar(rawLiteral, definition) + + // Expression + if (nextKeyScalar.isExpression) { + hasExpressionKey = true + + // Legal + if (definition.allowedContext.length > 0) { + const anyDefinition = new DefinitionInfo(definition, ANY) + mapping.add(nextKeyScalar, this.readValue(anyDefinition)) + } + // Illegal + else { + this._context.error( + nextKeyScalar, + "A template expression is not allowed in this context" + ) + this.skipValue() + } + + continue + } + + // Convert to StringToken if required + const nextKey = + nextKeyScalar.templateTokenType === TokenType.String + ? (nextKeyScalar as StringToken) + : new StringToken( + nextKeyScalar.file, + nextKeyScalar.range, + nextKeyScalar.toString(), + nextKeyScalar.definitionInfo + ) + + // Duplicate + const upperKey = nextKey.value.toUpperCase() + if (upperKeys[upperKey]) { + this._context.error(nextKey, `'${nextKey.value}' is already defined`) + this.skipValue() + continue + } + upperKeys[upperKey] = true + + // Well known + const nextPropertyDef = this._schema.matchPropertyAndFilter( + mappingDefinitions, + nextKey.value + ) + if (nextPropertyDef) { + const nextDefinition = new DefinitionInfo( + definition, + nextPropertyDef.type + ) + + // Store the definition on the key, the value may have its own definition + nextKey.definitionInfo = nextDefinition + + // If the property has a description, it's a parameter that uses a shared type + // and we need to make sure its description is set if there is one + if (nextPropertyDef.description) { + nextKey.description = nextPropertyDef.description + } + + const nextValue = this.readValue(nextDefinition) + + mapping.add(nextKey, nextValue) + continue + } + + // Loose + if (looseKeyType) { + if (!looseKeyDefinition) { + looseKeyDefinition = new DefinitionInfo(definition, looseKeyType) + looseValueDefinition = new DefinitionInfo(definition, looseValueType!) + } + + this.validate(nextKey, looseKeyDefinition) + + // Store the definition on the key, the value may have its own definition + const nextDefinition = new DefinitionInfo( + definition, + mappingDefinitions[0].looseValueType + ) + nextKey.definitionInfo = nextDefinition + + const nextValue = this.readValue(looseValueDefinition!) + mapping.add(nextKey, nextValue) + continue + } + + // Error + this._context.error(nextKey, `Unexpected value '${nextKey.value}'`) + this.skipValue() + } + + // If we matched a single definition from multiple, + // update the token's definition to enable more specific editor + // completion and validation + if (mappingDefinitions.length === 1) { + mapping.definitionInfo = new DefinitionInfo( + definition, + mappingDefinitions[0] + ) + } + + // Unable to filter to one definition + if (mappingDefinitions.length > 1) { + const hitCount: { [key: string]: number } = {} + for (const mappingDefinition of mappingDefinitions) { + for (const key of Object.keys(mappingDefinition.properties)) { + hitCount[key] = (hitCount[key] ?? 0) + 1 + } + } + + const nonDuplicates: string[] = [] + for (const key of Object.keys(hitCount)) { + if (hitCount[key] === 1) { + nonDuplicates.push(key) + } + } + + this._context.error( + mapping, + `There's not enough info to determine what you meant. Add one of these properties: ${nonDuplicates + .sort() + .join(", ")}` + ) + } + // Check required properties + else if (mappingDefinitions.length === 1 && !hasExpressionKey) { + for (const propertyName of Object.keys( + mappingDefinitions[0].properties + )) { + const propertyDef = mappingDefinitions[0].properties[propertyName] + if (propertyDef.required && !upperKeys[propertyName.toUpperCase()]) { + this._context.error( + mapping, + `Required property is missing: ${propertyName}` + ) + } + } + } + + this.expectMappingEnd() + } + + private handleMappingWithAllLooseProperties( + definition: DefinitionInfo, + keyDefinition: DefinitionInfo, + valueDefinition: DefinitionInfo, + mappingDefinition: MappingDefinition, + mapping: MappingToken + ): void { + let nextValue: TemplateToken + const upperKeys: { [key: string]: boolean } = {} + + let rawLiteral: LiteralToken | undefined + while ((rawLiteral = this._objectReader.allowLiteral())) { + const nextKeyScalar = this.parseScalar(rawLiteral, definition) + nextKeyScalar.definitionInfo = keyDefinition + + // Expression + if (nextKeyScalar.isExpression) { + // Legal + if (definition.allowedContext.length > 0) { + nextValue = this.readValue(valueDefinition) + mapping.add(nextKeyScalar, nextValue) + } + // Illegal + else { + this._context.error( + nextKeyScalar, + "A template expression is not allowed in this context" + ) + this.skipValue() + } + + continue + } + + // Convert to StringToken if required + const nextKey = + nextKeyScalar.templateTokenType === TokenType.String + ? (nextKeyScalar as StringToken) + : new StringToken( + nextKeyScalar.file, + nextKeyScalar.range, + nextKeyScalar.toString(), + nextKeyScalar.definitionInfo + ) + + // Duplicate + const upperKey = nextKey.value.toUpperCase() + if (upperKeys[upperKey]) { + this._context.error(nextKey, `'${nextKey.value}' is already defined`) + this.skipValue() + continue + } + upperKeys[upperKey] = true + + // Validate + this.validate(nextKey, keyDefinition) + + // Store the definition on the key, the value may have its own definition + const nextDefinition = new DefinitionInfo( + definition, + mappingDefinition.looseValueType + ) + nextKey.definitionInfo = nextDefinition + + // Add the pair + nextValue = this.readValue(valueDefinition) + mapping.add(nextKey, nextValue) + } + + this.expectMappingEnd() + } + + private expectMappingEnd(): void { + if (!this._objectReader.allowMappingEnd()) { + throw new Error("Expected mapping end") // Should never happen + } + } + + private skipValue(): void { + // Scalar + if (this._objectReader.allowLiteral()) { + // Intentionally empty + } + // Sequence + else if (this._objectReader.allowSequenceStart()) { + while (!this._objectReader.allowSequenceEnd()) { + this.skipValue() + } + } + // Mapping + else if (this._objectReader.allowMappingStart()) { + while (!this._objectReader.allowMappingEnd()) { + this.skipValue() + this.skipValue() + } + } + // Unexpected + else { + throw new Error("Expected a scalar value, a sequence, or a mapping") + } + } + + private validate( + scalar: ScalarToken, + definition: DefinitionInfo + ): ScalarToken { + switch (scalar.templateTokenType) { + case TokenType.Null: + case TokenType.Boolean: + case TokenType.Number: + case TokenType.String: { + const literal = scalar as LiteralToken + + // Legal + const scalarDefinitions = definition.getScalarDefinitions() + let relevantDefinition: ScalarDefinition | undefined + if ( + (relevantDefinition = scalarDefinitions.find((x) => + x.isMatch(literal) + )) + ) { + scalar.definitionInfo = new DefinitionInfo( + definition, + relevantDefinition + ) + return scalar + } + + // Not a string, convert + if (literal.templateTokenType !== TokenType.String) { + const stringLiteral = new StringToken( + literal.file, + literal.range, + literal.toString(), + literal.definitionInfo + ) + + // Legal + if ( + (relevantDefinition = scalarDefinitions.find((x) => + x.isMatch(stringLiteral) + )) + ) { + stringLiteral.definitionInfo = new DefinitionInfo( + definition, + relevantDefinition + ) + return stringLiteral + } + } + + // Illegal + this._context.error(literal, `Unexpected value '${literal.toString()}'`) + return scalar + } + case TokenType.BasicExpression: + // Illegal + if (definition.allowedContext.length === 0) { + this._context.error( + scalar, + "A template expression is not allowed in this context" + ) + } + + return scalar + default: + this._context.error(scalar, `Unexpected value '${scalar.toString()}'`) + return scalar + } + } + + private parseScalar( + token: LiteralToken, + definitionInfo: DefinitionInfo + ): ScalarToken { + // Not a string + if (!isString(token)) { + return token + } + + const allowedContext = definitionInfo.allowedContext + const raw = token.source || token.value + + // Check if the value is definitely a literal + let startExpression: number = raw.indexOf(OPEN_EXPRESSION) + if (startExpression < 0) { + // Doesn't contain "${{" + // Check if value should still be evaluated as an expression + if ( + definitionInfo.definition instanceof StringDefinition && + definitionInfo.definition.isExpression + ) { + const expression = this.parseIntoExpressionToken( + token.range!, + raw, + allowedContext, + token + ) + if (expression) { + return expression + } + } + return token + } + + // Break the value into segments of LiteralToken and ExpressionToken + let encounteredError = false + const segments: ScalarToken[] = [] + let i = 0 + while (i < raw.length) { + // An expression starts here + if (i === startExpression) { + // Find the end of the expression - i.e. "}}" + startExpression = i + let endExpression = -1 + let inString = false + for (i += OPEN_EXPRESSION.length; i < raw.length; i++) { + if (raw[i] === "'") { + inString = !inString // Note, this handles escaped single quotes gracefully. E.x. 'foo''bar' + } else if (!inString && raw[i] === "}" && raw[i - 1] === "}") { + endExpression = i + i++ + break + } + } + + // Check if not closed + if (endExpression < startExpression) { + this._context.error( + token, + "The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found." + ) + return token + } + + // Parse the expression + const rawExpression = raw.substr( + startExpression + OPEN_EXPRESSION.length, + endExpression - + startExpression + + 1 - + OPEN_EXPRESSION.length - + CLOSE_EXPRESSION.length + ) + + let tr = token.range! + if (tr.start[0] === tr.end[0]) { + // If it's a single line expression, adjust the range to only cover the sub-expression + tr = { + start: [tr.start[0], tr.start[1] + startExpression], + end: [tr.end[0], tr.start[1] + endExpression + 1], + } + } else { + // Adjust the range to only cover the expression for multi-line strings + const startRaw = raw.substring(0, startExpression) + const adjustedStartLine = startRaw.split("\n").length + const beginningOfLine = startRaw.lastIndexOf("\n") + const adjustedStart = startExpression - beginningOfLine + const adjustedEnd = endExpression - beginningOfLine + 1 + + tr = { + start: [tr.start[0] + adjustedStartLine, adjustedStart], + end: [tr.start[0] + adjustedStartLine, adjustedEnd], + } + } + + const expression = this.parseIntoExpressionToken( + tr, + rawExpression, + allowedContext, + token + ) + + if (!expression) { + // Record that we've hit an error but continue to validate any other expressions + // that might be in the string + encounteredError = true + } else { + // Check if a directive was used when not allowed + if ( + expression.directive && + (startExpression !== 0 || i < raw.length) + ) { + this._context.error( + token, + `The directive '${expression.directive}' is not allowed in this context. Directives are not supported for expressions that are embedded within a string. Directives are only supported when the entire value is an expression.` + ) + return token + } + + // Add the segment + segments.push(expression) + } + + // Look for the next expression + startExpression = raw.indexOf(OPEN_EXPRESSION, i) + } + // The next expression is further ahead + else if (i < startExpression) { + // Append the segment + this.addString( + segments, + token.range, + raw.substr(i, startExpression - i), + token.definitionInfo + ) + + // Adjust the position + i = startExpression + } + // No remaining expressions + else { + this.addString( + segments, + token.range, + raw.substr(i), + token.definitionInfo + ) + break + } + } + + // If we've hit any error during parsing, return the original token + if (encounteredError) { + return token + } + + // Check if can convert to a literal + // For example, the escaped expression: ${{ '{{ this is a literal }}' }} + if ( + segments.length === 1 && + segments[0].templateTokenType === TokenType.BasicExpression + ) { + const basicExpression = segments[0] as BasicExpressionToken + const str = this.getExpressionString(basicExpression.expression) + if (str !== undefined) { + return new StringToken( + this._fileId, + token.range, + str, + token.definitionInfo + ) + } + } + + // Check if only one segment + if (segments.length === 1) { + return segments[0] + } + + // Build the new expression, using the format function + const format: string[] = [] + const args: string[] = [] + const expressionTokens: BasicExpressionToken[] = [] + let argIndex = 0 + for (const segment of segments) { + if (isString(segment)) { + const text = segment.value + .replace(/'/g, "''") // Escape quotes + .replace(/\{/g, "{{") // Escape braces + .replace(/\}/g, "}}") + format.push(text) + } else { + format.push(`{${argIndex}}`) // Append format arg + argIndex++ + + const expression = segment as BasicExpressionToken + args.push(", ") + args.push(expression.expression) + + expressionTokens.push(expression) + } + } + + return new BasicExpressionToken( + this._fileId, + token.range, + `format('${format.join("")}'${args.join("")})`, + token.definitionInfo, + expressionTokens + ) + } + + private parseIntoExpressionToken( + tr: TokenRange, + rawExpression: string, + allowedContext: string[], + token: TemplateToken + ): ExpressionToken | undefined { + const parseExpressionResult = this.parseExpression( + tr, + rawExpression, + allowedContext, + token.definitionInfo + ) + + // Check for error + if (parseExpressionResult.error) { + this._context.error(token, parseExpressionResult.error, tr) + return undefined + } + + return parseExpressionResult.expression! + } + + private parseExpression( + range: TokenRange | undefined, + value: string, + allowedContext: string[], + definitionInfo: DefinitionInfo | undefined + ): ParseExpressionResult { + const trimmed = value.trim() + + // Check if the value is empty + if (!trimmed) { + return { + error: new Error("An expression was expected"), + } + } + + // Try to find a matching directive + const matchDirectiveResult = this.matchDirective( + trimmed, + INSERT_DIRECTIVE, + 0 + ) + if (matchDirectiveResult.isMatch) { + return { + expression: new InsertExpressionToken( + this._fileId, + range, + definitionInfo + ), + } + } else if (matchDirectiveResult.error) { + return { + error: matchDirectiveResult.error, + } + } + + // Check if valid expression + try { + ExpressionToken.validateExpression(trimmed, allowedContext) + } catch (err) { + return { + error: err, + } + } + + // Return the expression + return { + expression: new BasicExpressionToken( + this._fileId, + range, + trimmed, + definitionInfo, + undefined + ), + error: undefined, + } + } + + private addString( + segments: ScalarToken[], + range: TokenRange | undefined, + value: string, + definition: DefinitionInfo | undefined + ): void { + // If the last segment was a LiteralToken, then append to the last segment + if ( + segments.length > 0 && + segments[segments.length - 1].templateTokenType === TokenType.String + ) { + const lastSegment = segments[segments.length - 1] as StringToken + segments[segments.length - 1] = new StringToken( + this._fileId, + range, + `${lastSegment.value}${value}`, + definition + ) + } + // Otherwise add a new LiteralToken + else { + segments.push(new StringToken(this._fileId, range, value, definition)) + } + } + + private matchDirective( + trimmed: string, + directive: string, + expectedParameters: number + ): MatchDirectiveResult { + const parameters: string[] = [] + if ( + trimmed.startsWith(directive) && + (trimmed.length === directive.length || + WHITESPACE_PATTERN.test(trimmed[directive.length])) + ) { + let startIndex = directive.length + let inString = false + let parens = 0 + for (let i = startIndex; i < trimmed.length; i++) { + const c = trimmed[i] + if (WHITESPACE_PATTERN.test(c) && !inString && parens == 0) { + if (startIndex < 1) { + parameters.push(trimmed.substr(startIndex, i - startIndex)) + } + + startIndex = i + 1 + } else if (c === "'") { + inString = !inString + } else if (c === "(" && !inString) { + parens++ + } else if (c === ")" && !inString) { + parens-- + } + } + + if (startIndex < trimmed.length) { + parameters.push(trimmed.substr(startIndex)) + } + + if (expectedParameters != parameters.length) { + return { + isMatch: false, + parameters: [], + error: new Error( + `Exactly ${expectedParameters} parameter(s) were expected following the directive '${directive}'. Actual parameter count: ${parameters.length}` + ), + } + } + + return { + isMatch: true, + parameters: parameters, + } + } + + return { + isMatch: false, + parameters: parameters, + } + } + + private getExpressionString(trimmed: string): string | undefined { + const result: string[] = [] + + let inString = false + for (let i = 0; i < trimmed.length; i++) { + const c = trimmed[i] + if (c === "'") { + inString = !inString + if (inString && i !== 0) { + result.push(c) + } + } else if (!inString) { + return undefined + } else { + result.push(c) + } + } + + return result.join("") + } +} + +interface ParseExpressionResult { + expression: ExpressionToken | undefined + error: Error | undefined +} + +interface MatchDirectiveResult { + isMatch: boolean + parameters: string[] + error: Error | undefined +} diff --git a/actions-workflow-parser/src/templates/template-unraveler.ts b/actions-workflow-parser/src/templates/template-unraveler.ts new file mode 100644 index 0000000..781d18c --- /dev/null +++ b/actions-workflow-parser/src/templates/template-unraveler.ts @@ -0,0 +1,1041 @@ +// Does just-in-time expression expansion + +import { + evaluateMappingToken, + evaluateStringToken, + evaluateTemplateToken, +} from "./evaluate-expression" +import { CLOSE_EXPRESSION, OPEN_EXPRESSION } from "./template-constants" +import { TemplateContext } from "./template-context" +import { + BasicExpressionToken, + InsertExpressionToken, + LiteralToken, + MappingToken, + ScalarToken, + SequenceToken, + StringToken, + TemplateToken, +} from "./tokens" +import { TokenType } from "./tokens/types" + +/** + * This class allows callers to easily traverse a template object. + * This class hides the details of expression expansion, depth tracking, + * and memory tracking. + */ +export class TemplateUnraveler { + private readonly _context: TemplateContext + private _current: ReaderState | undefined + private _expanded = false + + public constructor( + context: TemplateContext, + template: TemplateToken, + removeBytes: number + ) { + this._context = context + + // Initialize the reader state + this.moveFirst(template, removeBytes) + } + + public allowScalar(expand: boolean): ScalarToken | undefined { + if (expand) { + this.unravel(true) + } + + if (this._current?.value.isScalar) { + const scalar = this._current.value as ScalarToken + this.moveNext() + return scalar + } + + return undefined + } + + public allowSequenceStart(expand: boolean): SequenceToken | undefined { + if (expand) { + this.unravel(true) + } + + if ( + this._current?.value.templateTokenType === TokenType.Sequence && + (this._current as SequenceState).isStart + ) { + const sequence = new SequenceToken( + this._current.value.file, + this._current.value.range, + this._current.value.definitionInfo + ) + this.moveNext() + return sequence + } + + return undefined + } + + public allowSequenceEnd(expand: boolean): boolean { + if (expand) { + this.unravel(true) + } + + if ( + this._current?.value.templateTokenType === TokenType.Sequence && + (this._current as SequenceState).isEnd + ) { + this.moveNext() + return true + } + + return false + } + + public allowMappingStart(expand: boolean): MappingToken | undefined { + if (expand) { + this.unravel(true) + } + + if ( + this._current?.value.templateTokenType === TokenType.Mapping && + (this._current as MappingState).isStart + ) { + const mapping = new MappingToken( + this._current.value.file, + this._current.value.range, + this._current.value.definitionInfo + ) + this.moveNext() + return mapping + } + + return undefined + } + + public allowMappingEnd(expand: boolean): boolean { + if (expand) { + this.unravel(true) + } + + if ( + this._current?.value.templateTokenType === TokenType.Mapping && + (this._current as MappingState).isEnd + ) { + this.moveNext() + return true + } + + return false + } + + public readEnd(): void { + if (this._current !== undefined) { + throw new Error( + `Expected end of template object. ${this.dumpState("readEnd")}` + ) + } + } + + public readMappingEnd(): void { + if (!this.allowMappingEnd(false)) { + throw new Error( + `Unexpected state while attempting to read the mapping end. ${this.dumpState( + "readMappingEnd" + )}` + ) + } + } + + public skipSequenceItem(): void { + if (this._current?.parent?.value.templateTokenType !== TokenType.Sequence) { + throw new Error( + `Unexpected state while attempting to skip the current sequence item. ${this.dumpState( + "skipSequenceItem" + )}` + ) + } + + this.moveNext(true) + } + + public skipMappingKey(): void { + if ( + this._current?.parent?.value.templateTokenType !== TokenType.Mapping || + !(this._current.parent as MappingState).isKey + ) { + throw new Error( + `Unexpected state while attempting to skip the current mapping key. ${this.dumpState( + "skipMappingKey" + )}` + ) + } + + this.moveNext(true) + } + + public skipMappingValue(): void { + if ( + this._current?.parent?.value.templateTokenType !== TokenType.Mapping || + (this._current.parent as MappingState).isKey + ) { + throw new Error( + `Unexpected state while attempting to skip the current mapping value. ${this.dumpState( + "skipMappingValue" + )}` + ) + } + + this.moveNext(true) + } + + private dumpState(operation: string): string { + const result: string[] = [] + + if (operation) { + result.push(`Operation: ${operation}`) + } + + if (this._current === undefined) { + result.push(`State: (null)`) + } else { + result.push(`State:`) + result.push("") + + // Push state hierarchy + const stack: ReaderState[] = [] + let curr: ReaderState | undefined = this._current + while (curr) { + result.push(curr.toString()) + curr = curr.parent + } + } + + return result.join("\n") + } + + private moveFirst(value: TemplateToken, removeBytes: number): void { + switch (value.templateTokenType) { + case TokenType.Null: + case TokenType.Boolean: + case TokenType.Number: + case TokenType.String: + case TokenType.Sequence: + case TokenType.Mapping: + case TokenType.BasicExpression: + break + default: + throw new Error( + `Unexpected type '${value.typeName()}' when initializing object reader state` + ) + } + + this._current = ReaderState.createState(undefined, value, this._context) + } + + private moveNext(skipNestedEvents?: boolean): void { + if (this._current === undefined) { + return + } + + // Sequence start + if ( + this._current.value.templateTokenType === TokenType.Sequence && + (this._current as SequenceState).isStart && + !skipNestedEvents + ) { + // Move to the first item or sequence end + const sequenceState = this._current as SequenceState + this._current = sequenceState.next() + } + // Mapping state + else if ( + this._current.value.templateTokenType === TokenType.Mapping && + (this._current as MappingState).isStart && + !skipNestedEvents + ) { + // Move to the first item key or mapping end + const mappingState = this._current as MappingState + this._current = mappingState.next() + } + // Parent is a sequence + else if ( + this._current.parent?.value.templateTokenType === TokenType.Sequence + ) { + // Move to the next item or sequence end + const parentSequenceState = this._current.parent as SequenceState + this._current = parentSequenceState.next() + } + // Parent is a mapping + else if ( + this._current.parent?.value.templateTokenType === TokenType.Mapping + ) { + // Move to the next item value, item key, or mapping end + const parentMappingState = this._current.parent as MappingState + this._current = parentMappingState.next() + } + // Parent is an expression end + else if (this._current.parent !== undefined) { + this._current = this._current.parent + } + // Parent is undefined + else { + this._current = undefined + } + + this._expanded = false + this.unravel(false) + } + + private unravel(expand: boolean): void { + if (this._expanded) { + return + } + + for (;;) { + if (this._current === undefined) { + break + } + // Literal + else if (this._current.value.isLiteral) { + break + } + // Basic expression + else if ( + this._current.value.templateTokenType === TokenType.BasicExpression + ) { + const basicExpressionState = this._current as BasicExpressionState + + // Sequence item is a basic expression start + // For example: + // steps: + // - script: credscan + // - ${{ parameters.preBuild }} + // - script: build + if ( + basicExpressionState.isStart && + this._current.parent?.value.templateTokenType === TokenType.Sequence + ) { + if (expand) { + this.sequenceItemBasicExpression() + } else { + break + } + } + // Mapping key is a basic expression start + // For example: + // steps: + // - ${{ parameters.scriptHost }}: echo hi + else if ( + basicExpressionState.isStart && + this._current.parent?.value.templateTokenType === TokenType.Mapping && + (this._current.parent as MappingState).isKey + ) { + if (expand) { + this.mappingKeyBasicExpression() + } else { + break + } + } + // Mapping value is a basic expression start + // For example: + // steps: + // - script: credscan + // - script: ${{ parameters.tool }} + else if ( + basicExpressionState.isStart && + this._current.parent?.value.templateTokenType === TokenType.Mapping && + !(this._current.parent as MappingState).isKey + ) { + if (expand) { + this.mappingValueBasicExpression() + } else { + break + } + } + // Root basic expression start + else if ( + basicExpressionState.isStart && + this._current.parent === undefined + ) { + if (expand) { + this.rootBasicExpression() + } else { + break + } + } + // Basic expression end + else if (basicExpressionState.isEnd) { + this.endExpression() + } else { + this.unexpectedState("unravel basic expression") + } + } + // Mapping + else if (this._current.value.templateTokenType === TokenType.Mapping) { + const mappingState = this._current as MappingState + + // Mapping end, closing an "insert" mapping insertion + if ( + mappingState.isEnd && + this._current.parent?.value.templateTokenType === + TokenType.InsertExpression + ) { + this._current = this._current.parent // Skip to the expression end + } + // Normal mapping start + else if (mappingState.isStart) { + break + } + // Normal mapping end + else if (mappingState.isEnd) { + break + } else { + this.unexpectedState("unravel mapping") + } + } + // Sequence + else if (this._current.value.templateTokenType === TokenType.Sequence) { + const sequenceState = this._current as SequenceState + + // Sequence end, closing a sequence insertion + if ( + sequenceState.isEnd && + this._current.parent?.value.templateTokenType === + TokenType.BasicExpression && + this._current.parent.parent?.value.templateTokenType === + TokenType.Sequence + ) { + this._current = this._current.parent // Skip to the expression end + } + // Normal sequence start + else if (sequenceState.isStart) { + break + } + // Normal sequence end + else if (sequenceState.isEnd) { + break + } else { + this.unexpectedState("unravel sequence") + } + } + // Insert expression + else if ( + this._current.value.templateTokenType === TokenType.InsertExpression + ) { + const insertExpressionState = this._current as InsertExpressionState + + // Mapping key, beginning an "insert" mapping insertion + // For example: + // - job: a + // variables: + // ${{ insert }}: ${{ parameters.jobVariables }} + if ( + insertExpressionState.isStart && + this._current.parent?.value.templateTokenType === TokenType.Mapping && + (this._current.parent as MappingState).isKey + ) { + if (expand) { + this.startMappingInsertion() + } else { + break + } + } + // Expression end + else if (insertExpressionState.isEnd) { + this.endExpression() + } + // Not allowed + else if (insertExpressionState.isStart) { + this._context.error( + insertExpressionState.value, + `The expression directive '${insertExpressionState.expression.directive}' is not supported in this context` + ) + this._current = insertExpressionState.toStringToken() + } else { + this.unexpectedState("unravel insert expression") + } + } else { + this.unexpectedState("unravel") + } + } + + this._expanded = expand + } + + private sequenceItemBasicExpression() { + // The template looks like: + // + // steps: + // - ${{ parameters.preSteps }} + // - script: build + // + // The current state looks like: + // + // MappingState // The document starts with a mapping + // + // SequenceState // The "steps" sequence + // + // BasicExpressionState // m_current + + const expressionState = this._current as BasicExpressionState + const expression = expressionState.value as BasicExpressionToken + let value: TemplateToken | undefined + try { + value = evaluateTemplateToken(expression, expressionState.context) + } catch (err) { + this._context.error(expression, err) + } + + // Move to the nested sequence, skip the sequence start + if (value?.templateTokenType === TokenType.Sequence) { + this._current = expressionState.next(value, true) + } + // Move to the new value + else if (value !== undefined) { + this._current = expressionState.next(value, false) + } + // Move to the expression end + else if (value === undefined) { + expressionState.end() + } + } + + private mappingKeyBasicExpression(): void { + // The template looks like: + // + // steps: + // - ${{ parameters.scriptHost }}: echo hi + // + // The current state looks like: + // + // MappingState // The document starts with a mapping + // + // SequenceState // The "steps" sequence + // + // MappingState // The step mapping + // + // BasicExpressionState // m_current + + // The expression should evaluate to a string + const expressionState = this._current as BasicExpressionState + const expression = expressionState.value as BasicExpressionToken + let stringToken: StringToken | undefined + try { + const result = evaluateStringToken(expression, expressionState.context) + stringToken = result as StringToken + } catch (err) { + this._context.error(expression, err) + } + + // Move to the stringToken + if (stringToken !== undefined) { + this._current = expressionState.next(stringToken, false) + } + // Move to the next key or mapping end + else { + const parentMappingState = this._current!.parent as MappingState + this._current = parentMappingState.next() // Next key or mapping end + } + } + + private mappingValueBasicExpression(): void { + // The template looks like: + // + // steps: + // - script: credScan + // - script: ${{ parameters.tool }} + // + // The current state looks like: + // + // MappingState // The document starts with a mapping + // + // SequenceState // The "steps" sequence + // + // MappingState // The step mapping + // + // BasicExpressionState // m_current + + const expressionState = this._current as BasicExpressionState + const expression = expressionState.value as BasicExpressionToken + let value: TemplateToken + try { + value = evaluateTemplateToken(expression, expressionState.context) + } catch (err) { + this._context.error(expression, err) + value = new StringToken( + expression.file, + expression.range, + "", + expression.definitionInfo + ) + } + + // Move to the new value + this._current = expressionState.next(value, false) + } + + private rootBasicExpression() { + // The template looks like: + // + // ${{ parameters.tool }} + // + // The current state looks like: + // + // BasicExpressionState // m_current + + const expressionState = this._current as BasicExpressionState + const expression = expressionState.value as BasicExpressionToken + let value: TemplateToken + try { + value = evaluateTemplateToken(expression, expressionState.context) + } catch (err) { + this._context.error(expression, err) + value = new StringToken( + expression.file, + expression.range, + "", + expression.definitionInfo + ) + } + + // Move to the new value + this._current = expressionState.next(value, false) + } + + private startMappingInsertion() { + // The template looks like: + // + // jobs: + // - job: a + // variables: + // ${{ insert }}: ${{ parameters.jobVariables }} + // + // The current state looks like: + // + // MappingState // The document starts with a mapping + // + // SequenceState // The "jobs" sequence + // + // MappingState // The "job" mapping + // + // MappingState // The "variables" mapping + // + // InsertExpressionState // m_current + + const expressionState = this._current as InsertExpressionState + const parentMappingState = expressionState.parent as MappingState + const nestedValue = parentMappingState.mapping.get( + parentMappingState.index + ).value + let nestedMapping: MappingToken | undefined + if (nestedValue.templateTokenType === TokenType.Mapping) { + nestedMapping = nestedValue as MappingToken + } else if (nestedValue.templateTokenType === TokenType.BasicExpression) { + const basicExpression = nestedValue as BasicExpressionToken + + // The expression should evaluate to a mapping + try { + nestedMapping = evaluateMappingToken( + basicExpression, + expressionState.context + ) + } catch (err) { + this._context.error(basicExpression, err) + } + } else { + this._context.error(nestedValue, "Expected a mapping") + } + + // Move to the nested first key + if ((nestedMapping?.count ?? 0) > 0) { + this._current = expressionState.next(nestedMapping!) + } + // Move to the expression end + else { + expressionState.end() + } + } + + private endExpression(): void { + if (!this._current) { + throw new Error("_current should not be null") + } + + // End of document + if (this._current.parent === undefined) { + this._current = undefined + } + // End basic expression + else if ( + this._current.value.templateTokenType === TokenType.BasicExpression + ) { + // Move to the next item or sequence end + if (this._current.parent.value.templateTokenType === TokenType.Sequence) { + const parentSequenceState = this._current.parent as SequenceState + this._current = parentSequenceState.next() + } + // Move to the next key, next value, or mapping end + else { + const parentMappingState = this._current.parent as MappingState + this._current = parentMappingState.next() + } + } + // End "insert" mapping insertion + else { + // Move to the next key or mapping end + const parentMappingState = this._current.parent as MappingState + this._current = parentMappingState.next() + } + } + + private unexpectedState(operation: string): void { + throw new Error( + `Unexpected state while unraveling expressions. ${this.dumpState( + operation + )}` + ) + } +} + +abstract class ReaderState { + public readonly parent: ReaderState | undefined + public readonly value: TemplateToken + public readonly context: TemplateContext + + public constructor( + parent: ReaderState | undefined, + value: TemplateToken, + context: TemplateContext + ) { + this.parent = parent + this.value = value + this.context = context + } + + public abstract toString(): string + + public static createState( + parent: ReaderState | undefined, + value: TemplateToken, + context: TemplateContext + ) { + switch (value.templateTokenType) { + case TokenType.Null: + case TokenType.Boolean: + case TokenType.Number: + case TokenType.String: + return new LiteralState(parent, value as LiteralToken, context) + case TokenType.Sequence: + return new SequenceState(parent, value as SequenceToken, context) + case TokenType.Mapping: + return new MappingState(parent, value as MappingToken, context) + case TokenType.BasicExpression: + return new BasicExpressionState( + parent, + value as BasicExpressionToken, + context + ) + case TokenType.InsertExpression: + return new InsertExpressionState( + parent, + value as InsertExpressionToken, + context + ) + default: + throw new Error( + `Unexpected type '${value.typeName()}' when constructing reader state` + ) + } + } +} + +class LiteralState extends ReaderState { + public constructor( + parent: ReaderState | undefined, + literal: LiteralToken, + context: TemplateContext + ) { + super(parent, literal, context) + } + + public override toString(): string { + const result: string[] = [] + result.push("LiteralState") + return `${result.join("\n")}\n` + } +} + +class SequenceState extends ReaderState { + private _isStart = true + private _index = 0 + + public constructor( + parent: ReaderState | undefined, + sequence: SequenceToken, + context: TemplateContext + ) { + super(parent, sequence, context) + } + + /** + * Indicates whether the state represents the sequence-start event + */ + public get isStart(): boolean { + return this._isStart + } + + /** + * The current index within the sequence + */ + public get index(): number { + return this._index + } + + /** + * Indicates whether the state represents the sequence-end event + */ + public get isEnd(): boolean { + return !this.isStart && this.index >= this.sequence.count + } + + public get sequence(): SequenceToken { + return this.value as SequenceToken + } + + public next(): ReaderState { + // Adjust the state + if (this._isStart) { + this._isStart = false + } else { + this._index++ + } + + // Return the next event + if (!this.isEnd) { + return ReaderState.createState( + this, + this.sequence.get(this._index), + this.context + ) + } else { + return this + } + } + + public override toString(): string { + const result: string[] = [] + result.push("SequenceState:") + result.push(` isStart: ${this._isStart}`) + result.push(` index: ${this._index}`) + result.push(` isEnd: ${this.isEnd}`) + return `${result.join("\n")}\n` + } +} + +class MappingState extends ReaderState { + private _isStart = true + private _index = 0 + private _isKey = false + + public constructor( + parent: ReaderState | undefined, + mapping: MappingToken, + context: TemplateContext + ) { + super(parent, mapping, context) + } + + /** + * Indicates whether the state represents the mapping-start event + */ + public get isStart(): boolean { + return this._isStart + } + + /** + * The current index within the mapping + */ + public get index(): number { + return this._index + } + + /** + * Indicates whether the state represents a mapping-key position + */ + public get isKey(): boolean { + return this._isKey + } + + /** + * Indicates whether the state represents the mapping-end event + */ + public get isEnd(): boolean { + return !this._isStart && this._index >= this.mapping.count + } + + public get mapping(): MappingToken { + return this.value as MappingToken + } + + public next(): ReaderState { + // Adjust the state + if (this._isStart) { + this._isStart = false + this._isKey = true + } else if (this._isKey) { + this._isKey = false + } else { + this._index++ + this._isKey = true + } + + // Return the next event + if (!this.isEnd) { + if (this._isKey) { + return ReaderState.createState( + this, + this.mapping.get(this._index).key, + this.context + ) + } else { + return ReaderState.createState( + this, + this.mapping.get(this._index).value, + this.context + ) + } + } else { + return this + } + } + + public override toString(): string { + const result: string[] = [] + result.push("MappingState:") + result.push(` isStart: ${this._isStart}`) + result.push(` index: ${this._index}`) + result.push(` isKey: ${this._isKey}`) + result.push(` isEnd: ${this.isEnd}`) + return `${result.join("\n")}\n` + } +} + +class BasicExpressionState extends ReaderState { + private _isStart = true + + public constructor( + parent: ReaderState | undefined, + expression: BasicExpressionToken, + context: TemplateContext + ) { + super(parent, expression, context) + } + + /** + * Indicates whether entering the expression + */ + public get isStart(): boolean { + return this._isStart + } + + /** + * Indicates whether leaving the expression + */ + public get isEnd(): boolean { + return !this._isStart + } + + public next(value: TemplateToken, isSequenceInsertion: boolean): ReaderState { + // Adjust the state + this._isStart = false + + // Create the nested state + const nestedState = ReaderState.createState(this, value, this.context) + if (isSequenceInsertion) { + const nestedSequenceState = nestedState as SequenceState + return nestedSequenceState.next() // Skip the sequence start + } else { + return nestedState + } + } + + public end(): ReaderState { + this._isStart = false + return this + } + + public override toString(): string { + const result: string[] = [] + result.push("BasicExpressionState:") + result.push(` isStart: ${this._isStart}`) + return `${result.join("\n")}\n` + } +} + +class InsertExpressionState extends ReaderState { + private _isStart = true + + public constructor( + parent: ReaderState | undefined, + expression: InsertExpressionToken, + context: TemplateContext + ) { + super(parent, expression, context) + } + + /** + * Indicates whether entering or leaving the expression + */ + public get isStart(): boolean { + return this._isStart + } + + /** + * Indicates whether leaving the expression + */ + public get isEnd(): boolean { + return !this._isStart + } + + public get expression(): InsertExpressionToken { + return this.value as InsertExpressionToken + } + + public next(value: MappingToken): ReaderState { + // Adjust the state + this._isStart = false + + // Create the nested state + const nestedState = ReaderState.createState( + this, + value, + this.context + ) as MappingState + return nestedState.next() // Skip the mapping start + } + + public end(): ReaderState { + this._isStart = false + return this + } + + /** + * This happens when the expression is not allowed + */ + public toStringToken(): ReaderState { + const literal = new StringToken( + this.value.file, + this.value.range, + `${OPEN_EXPRESSION} ${this.expression.directive} ${CLOSE_EXPRESSION}`, + this.value.definitionInfo + ) + return ReaderState.createState(this.parent, literal, this.context) + } + + public override toString(): string { + const result: string[] = [] + result.push("InsertExpressionState:") + result.push(` isStart: ${this._isStart}`) + return `${result.join("\n")}\n` + } +} diff --git a/actions-workflow-parser/src/templates/template-validation-error.ts b/actions-workflow-parser/src/templates/template-validation-error.ts new file mode 100644 index 0000000..bcf01e6 --- /dev/null +++ b/actions-workflow-parser/src/templates/template-validation-error.ts @@ -0,0 +1,25 @@ +import { TokenRange } from "./tokens/token-range" + +/** + * Provides information about an error which occurred during validation + */ +export class TemplateValidationError { + constructor( + public readonly rawMessage: string, + public readonly prefix: string | undefined, + public readonly code: string | undefined, + public readonly range: TokenRange | undefined + ) {} + + public get message(): string { + if (this.prefix) { + return `${this.prefix}: ${this.rawMessage}` + } + + return this.rawMessage + } + + public toString(): string { + return this.message + } +} diff --git a/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts b/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts new file mode 100644 index 0000000..4b1d3ff --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts @@ -0,0 +1,68 @@ +import { DefinitionInfo } from "../schema/definition-info" + +import { CLOSE_EXPRESSION, OPEN_EXPRESSION } from "../template-constants" +import { ExpressionToken } from "./expression-token" +import { ScalarToken } from "./scalar-token" +import { SerializedExpressionToken } from "./serialization" +import { TemplateToken } from "./template-token" +import { TokenRange } from "./token-range" +import { TokenType } from "./types" + +export class BasicExpressionToken extends ExpressionToken { + private readonly expr: string + + public readonly originalExpressions: BasicExpressionToken[] | undefined + + /** + * @param originalExpressions If the basic expression was transformed from individual expressions, these will be the original ones + */ + public constructor( + file: number | undefined, + range: TokenRange | undefined, + expression: string, + definitionInfo: DefinitionInfo | undefined, + originalExpressions: BasicExpressionToken[] | undefined + ) { + super(TokenType.BasicExpression, file, range, undefined, definitionInfo) + this.expr = expression + this.originalExpressions = originalExpressions + } + + public get expression(): string { + return this.expr + } + + public override clone(omitSource?: boolean): TemplateToken { + return omitSource + ? new BasicExpressionToken( + undefined, + undefined, + this.expr, + this.definitionInfo, + this.originalExpressions + ) + : new BasicExpressionToken( + this.file, + this.range, + this.expr, + this.definitionInfo, + this.originalExpressions + ) + } + + public override toString(): string { + return `${OPEN_EXPRESSION} ${this.expr} ${CLOSE_EXPRESSION}` + } + + public override toDisplayString(): string { + // TODO: Implement expression display string to match `BasicExpressionToken#ToDisplayString()` in the C# parser + return ScalarToken.trimDisplayString(this.toString()) + } + + public override toJSON(): SerializedExpressionToken { + return { + type: TokenType.BasicExpression, + expr: this.expr, + } + } +} diff --git a/actions-workflow-parser/src/templates/tokens/boolean-token.ts b/actions-workflow-parser/src/templates/tokens/boolean-token.ts new file mode 100644 index 0000000..4488d6a --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/boolean-token.ts @@ -0,0 +1,36 @@ +import { LiteralToken, TemplateToken } from "." +import { DefinitionInfo } from "../schema/definition-info" +import { TokenRange } from "./token-range" +import { TokenType } from "./types" + +export class BooleanToken extends LiteralToken { + private readonly bool: boolean + + public constructor( + file: number | undefined, + range: TokenRange | undefined, + value: boolean, + definitionInfo: DefinitionInfo | undefined + ) { + super(TokenType.Boolean, file, range, definitionInfo) + this.bool = value + } + + public get value(): boolean { + return this.bool + } + + public override clone(omitSource?: boolean): TemplateToken { + return omitSource + ? new BooleanToken(undefined, undefined, this.bool, this.definitionInfo) + : new BooleanToken(this.file, this.range, this.bool, this.definitionInfo) + } + + public override toString(): string { + return this.bool ? "true" : "false" + } + + public override toJSON() { + return this.bool + } +} diff --git a/actions-workflow-parser/src/templates/tokens/expression-token.ts b/actions-workflow-parser/src/templates/tokens/expression-token.ts new file mode 100644 index 0000000..336052e --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/expression-token.ts @@ -0,0 +1,41 @@ +import { Lexer, Parser } from "@github/actions-expressions" +import { splitAllowedContext } from "../allowed-context" +import { DefinitionInfo } from "../schema/definition-info" +import { ScalarToken } from "./scalar-token" +import { TokenRange } from "./token-range" + +export abstract class ExpressionToken extends ScalarToken { + public readonly directive: string | undefined + + public constructor( + type: number, + file: number | undefined, + range: TokenRange | undefined, + directive: string | undefined, + definitionInfo: DefinitionInfo | undefined + ) { + super(type, file, range, definitionInfo) + this.directive = directive + } + + public override get isLiteral(): boolean { + return false + } + + public override get isExpression(): boolean { + return true + } + + public static validateExpression( + expression: string, + allowedContext: string[] + ): void { + const { namedContexts, functions } = splitAllowedContext(allowedContext) + + // Parse + const lexer = new Lexer(expression) + const result = lexer.lex() + const p = new Parser(result.tokens, namedContexts, functions) + p.parse() + } +} diff --git a/actions-workflow-parser/src/templates/tokens/index.ts b/actions-workflow-parser/src/templates/tokens/index.ts new file mode 100644 index 0000000..1da1088 --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/index.ts @@ -0,0 +1,13 @@ +export { TemplateToken } from "./template-token" +export { ScalarToken } from "./scalar-token" +export { LiteralToken } from "./literal-token" +export { StringToken } from "./string-token" +export { NumberToken } from "./number-token" +export { BooleanToken } from "./boolean-token" +export { NullToken } from "./null-token" +export { KeyValuePair } from "./key-value-pair" +export { SequenceToken } from "./sequence-token" +export { MappingToken } from "./mapping-token" +export { ExpressionToken } from "./expression-token" +export { BasicExpressionToken } from "./basic-expression-token" +export { InsertExpressionToken } from "./insert-expression-token" diff --git a/actions-workflow-parser/src/templates/tokens/insert-expression-token.ts b/actions-workflow-parser/src/templates/tokens/insert-expression-token.ts new file mode 100644 index 0000000..270e40d --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/insert-expression-token.ts @@ -0,0 +1,47 @@ +import { TemplateToken, ScalarToken, ExpressionToken } from "." +import { DefinitionInfo } from "../schema/definition-info" +import { + INSERT_DIRECTIVE, + OPEN_EXPRESSION, + CLOSE_EXPRESSION, +} from "../template-constants" +import { SerializedExpressionToken } from "./serialization" +import { TokenRange } from "./token-range" +import { TokenType } from "./types" + +export class InsertExpressionToken extends ExpressionToken { + public constructor( + file: number | undefined, + range: TokenRange | undefined, + definitionInfo: DefinitionInfo | undefined + ) { + super( + TokenType.InsertExpression, + file, + range, + INSERT_DIRECTIVE, + definitionInfo + ) + } + + public override clone(omitSource?: boolean): TemplateToken { + return omitSource + ? new InsertExpressionToken(undefined, undefined, this.definitionInfo) + : new InsertExpressionToken(this.file, this.range, this.definitionInfo) + } + + public override toString(): string { + return `${OPEN_EXPRESSION} ${INSERT_DIRECTIVE} ${CLOSE_EXPRESSION}` + } + + public override toDisplayString(): string { + return ScalarToken.trimDisplayString(this.toString()) + } + + public override toJSON(): SerializedExpressionToken { + return { + type: TokenType.InsertExpression, + expr: "insert", + } + } +} diff --git a/actions-workflow-parser/src/templates/tokens/key-value-pair.ts b/actions-workflow-parser/src/templates/tokens/key-value-pair.ts new file mode 100644 index 0000000..2ba6227 --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/key-value-pair.ts @@ -0,0 +1,11 @@ +import { ScalarToken } from "./scalar-token" +import { TemplateToken } from "./template-token" + +export class KeyValuePair { + public readonly key: ScalarToken + public readonly value: TemplateToken + public constructor(key: ScalarToken, value: TemplateToken) { + this.key = key + this.value = value + } +} diff --git a/actions-workflow-parser/src/templates/tokens/literal-token.ts b/actions-workflow-parser/src/templates/tokens/literal-token.ts new file mode 100644 index 0000000..aed8a67 --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/literal-token.ts @@ -0,0 +1,35 @@ +import { DefinitionInfo } from "../schema/definition-info" +import { ScalarToken } from "./scalar-token" +import { TokenRange } from "./token-range" + +export abstract class LiteralToken extends ScalarToken { + public constructor( + type: number, + file: number | undefined, + range: TokenRange | undefined, + definitionInfo: DefinitionInfo | undefined + ) { + super(type, file, range, definitionInfo) + } + + public override get isLiteral(): boolean { + return true + } + + public override get isExpression(): boolean { + return false + } + + public override toDisplayString(): string { + return ScalarToken.trimDisplayString(this.toString()) + } + + /** + * Throws a good debug message when an unexpected literal value is encountered + */ + public assertUnexpectedValue(objectDescription: string): void { + throw new Error( + `Error while reading '${objectDescription}'. Unexpected value '${this.toString()}'` + ) + } +} diff --git a/actions-workflow-parser/src/templates/tokens/mapping-token.ts b/actions-workflow-parser/src/templates/tokens/mapping-token.ts new file mode 100644 index 0000000..a0f934f --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/mapping-token.ts @@ -0,0 +1,80 @@ +import { TemplateToken, KeyValuePair, ScalarToken } from "." +import { DefinitionInfo } from "../schema/definition-info" +import { MapItem, SerializedToken } from "./serialization" +import { TokenRange } from "./token-range" +import { TokenType } from "./types" + +export class MappingToken extends TemplateToken { + private readonly map: KeyValuePair[] = [] + + public constructor( + file: number | undefined, + range: TokenRange | undefined, + definitionInfo: DefinitionInfo | undefined + ) { + super(TokenType.Mapping, file, range, definitionInfo) + } + + public get count(): number { + return this.map.length + } + + public override get isScalar(): boolean { + return false + } + + public override get isLiteral(): boolean { + return false + } + + public override get isExpression(): boolean { + return false + } + + public add(key: ScalarToken, value: TemplateToken): void { + this.map.push(new KeyValuePair(key, value)) + } + + public get(index: number): KeyValuePair { + return this.map[index] + } + + public find(key: string): TemplateToken | undefined { + const pair = this.map.find((pair) => pair.key.toString() === key) + return pair?.value + } + + public remove(index: number): void { + this.map.splice(index, 1) + } + + public override clone(omitSource?: boolean): TemplateToken { + const result = omitSource + ? new MappingToken(undefined, undefined, this.definitionInfo) + : new MappingToken(this.file, this.range, this.definitionInfo) + for (const item of this.map) { + result.add( + item.key.clone(omitSource) as ScalarToken, + item.value.clone(omitSource) + ) + } + return result + } + + public override toJSON(): SerializedToken { + const items: MapItem[] = [] + for (const item of this.map) { + items.push({ Key: item.key, Value: item.value }) + } + return { + type: TokenType.Mapping, + map: items, + } + } + + public *[Symbol.iterator](): Iterator { + for (const item of this.map) { + yield item + } + } +} diff --git a/actions-workflow-parser/src/templates/tokens/null-token.ts b/actions-workflow-parser/src/templates/tokens/null-token.ts new file mode 100644 index 0000000..313dd7c --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/null-token.ts @@ -0,0 +1,28 @@ +import { LiteralToken, TemplateToken } from "." +import { DefinitionInfo } from "../schema/definition-info" +import { TokenRange } from "./token-range" +import { TokenType } from "./types" + +export class NullToken extends LiteralToken { + public constructor( + file: number | undefined, + range: TokenRange | undefined, + definitionInfo: DefinitionInfo | undefined + ) { + super(TokenType.Null, file, range, definitionInfo) + } + + public override clone(omitSource?: boolean): TemplateToken { + return omitSource + ? new NullToken(undefined, undefined, this.definitionInfo) + : new NullToken(this.file, this.range, this.definitionInfo) + } + + public override toString(): string { + return "" + } + + public override toJSON() { + return "null" + } +} diff --git a/actions-workflow-parser/src/templates/tokens/number-token.ts b/actions-workflow-parser/src/templates/tokens/number-token.ts new file mode 100644 index 0000000..fbfeb99 --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/number-token.ts @@ -0,0 +1,36 @@ +import { LiteralToken, TemplateToken } from "." +import { DefinitionInfo } from "../schema/definition-info" +import { TokenRange } from "./token-range" +import { TokenType } from "./types" + +export class NumberToken extends LiteralToken { + private readonly num: number + + public constructor( + file: number | undefined, + range: TokenRange | undefined, + value: number, + definitionInfo: DefinitionInfo | undefined + ) { + super(TokenType.Number, file, range, definitionInfo) + this.num = value + } + + public get value(): number { + return this.num + } + + public override clone(omitSource?: boolean): TemplateToken { + return omitSource + ? new NumberToken(undefined, undefined, this.num, this.definitionInfo) + : new NumberToken(this.file, this.range, this.num, this.definitionInfo) + } + + public override toString(): string { + return `${this.num}` + } + + public override toJSON() { + return this.num + } +} diff --git a/actions-workflow-parser/src/templates/tokens/scalar-token.ts b/actions-workflow-parser/src/templates/tokens/scalar-token.ts new file mode 100644 index 0000000..66f66f6 --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/scalar-token.ts @@ -0,0 +1,41 @@ +import { DefinitionInfo } from "../schema/definition-info" +import { TemplateToken } from "./template-token" +import { TokenRange } from "./token-range" + +/** + * Base class for everything that is not a mapping or sequence + */ +export abstract class ScalarToken extends TemplateToken { + public constructor( + type: number, + file: number | undefined, + range: TokenRange | undefined, + definitionInfo: DefinitionInfo | undefined + ) { + super(type, file, range, definitionInfo) + } + + public abstract toString(): string + + public abstract toDisplayString(): string + + public override get isScalar(): boolean { + return true + } + + protected static trimDisplayString(displayString: string): string { + let firstLine = displayString.trimStart() + const firstNewLine = firstLine.indexOf("\n") + const firstCarriageReturn = firstLine.indexOf("\r") + if (firstNewLine >= 0 || firstCarriageReturn >= 0) { + firstLine = firstLine.substr( + 0, + Math.min( + firstNewLine >= 0 ? firstNewLine : Number.MAX_VALUE, + firstCarriageReturn >= 0 ? firstCarriageReturn : Number.MAX_VALUE + ) + ) + } + return firstLine + } +} diff --git a/actions-workflow-parser/src/templates/tokens/sequence-token.ts b/actions-workflow-parser/src/templates/tokens/sequence-token.ts new file mode 100644 index 0000000..ae353b4 --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/sequence-token.ts @@ -0,0 +1,64 @@ +import { TemplateToken } from "." +import { DefinitionInfo } from "../schema/definition-info" +import { SerializedSequenceToken } from "./serialization" +import { TokenRange } from "./token-range" +import { TokenType } from "./types" + +export class SequenceToken extends TemplateToken { + private readonly seq: TemplateToken[] = [] + + public constructor( + file: number | undefined, + range: TokenRange | undefined, + definitionInfo: DefinitionInfo | undefined + ) { + super(TokenType.Sequence, file, range, definitionInfo) + } + + public get count(): number { + return this.seq.length + } + + public override get isScalar(): boolean { + return false + } + + public override get isLiteral(): boolean { + return false + } + + public override get isExpression(): boolean { + return false + } + + public add(value: TemplateToken): void { + this.seq.push(value) + } + + public get(index: number): TemplateToken { + return this.seq[index] + } + + public override clone(omitSource?: boolean): TemplateToken { + const result = omitSource + ? new SequenceToken(undefined, undefined, this.definitionInfo) + : new SequenceToken(this.file, this.range, this.definitionInfo) + for (const item of this.seq) { + result.add(item.clone(omitSource)) + } + return result + } + + public override toJSON(): SerializedSequenceToken { + return { + type: TokenType.Sequence, + seq: this.seq, + } + } + + public *[Symbol.iterator](): Iterator { + for (const item of this.seq) { + yield item + } + } +} diff --git a/actions-workflow-parser/src/templates/tokens/serialization.ts b/actions-workflow-parser/src/templates/tokens/serialization.ts new file mode 100644 index 0000000..5ccc448 --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/serialization.ts @@ -0,0 +1,32 @@ +import { ScalarToken } from "./scalar-token" +import { TemplateToken } from "./template-token" +import { TokenType } from "./types" + +export type MapItem = { + Key: ScalarToken + Value: TemplateToken +} + +export type SerializedMappingToken = { + type: TokenType.Mapping + map: MapItem[] +} + +export type SerializedSequenceToken = { + type: TokenType.Sequence + seq: TemplateToken[] +} + +export type SerializedExpressionToken = { + type: TokenType.BasicExpression | TokenType.InsertExpression + expr: string +} + +export type SerializedToken = + | SerializedMappingToken + | SerializedSequenceToken + | SerializedExpressionToken + | string + | number + | boolean + | undefined diff --git a/actions-workflow-parser/src/templates/tokens/string-token.ts b/actions-workflow-parser/src/templates/tokens/string-token.ts new file mode 100644 index 0000000..7a434cf --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/string-token.ts @@ -0,0 +1,47 @@ +import { LiteralToken, TemplateToken } from "." +import { DefinitionInfo } from "../schema/definition-info" +import { TokenRange } from "./token-range" +import { TokenType } from "./types" + +export class StringToken extends LiteralToken { + public readonly value: string + public readonly source: string | undefined + + public constructor( + file: number | undefined, + range: TokenRange | undefined, + value: string, + definitionInfo: DefinitionInfo | undefined, + source?: string + ) { + super(TokenType.String, file, range, definitionInfo) + this.value = value + this.source = source + } + + public override clone(omitSource?: boolean): TemplateToken { + return omitSource + ? new StringToken( + undefined, + undefined, + this.value, + this.definitionInfo, + this.source + ) + : new StringToken( + this.file, + this.range, + this.value, + this.definitionInfo, + this.source + ) + } + + public override toString(): string { + return this.value + } + + public override toJSON() { + return this.value + } +} diff --git a/actions-workflow-parser/src/templates/tokens/template-token.test.ts b/actions-workflow-parser/src/templates/tokens/template-token.test.ts new file mode 100644 index 0000000..e6c0808 --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/template-token.test.ts @@ -0,0 +1,45 @@ +import { nullTrace } from "../../test-utils/null-trace" +import { parseWorkflow } from "../../workflows/workflow-parser" +import { StringToken } from "./string-token" +import { TemplateToken } from "./template-token" + +describe("traverse", () => { + it("returns parent token and key", () => { + const workflow = parseWorkflow( + "wf.yaml", + [ + { + name: "wf.yaml", + content: `on: push`, + }, + ], + nullTrace + ) + + const root = workflow.value! + const traverser = TemplateToken.traverse(root) + + // Root + expect(traverser.next()!.value).toEqual([undefined, root, undefined]) + + // On + const onResult = traverser.next()!.value! + expect(onResult[0]).toBe(root) + expect(getValue(onResult[1])).toEqual("on") + expect(onResult[2]).toBeUndefined() + + // Push + const pushResult = traverser.next()!.value! + expect(pushResult[0]).toBe(root) + expect(getValue(pushResult[1])).toEqual("push") + expect(getValue(pushResult[2])).toEqual("on") + }) +}) + +function getValue(token: TemplateToken | undefined): string { + if (token instanceof StringToken) { + return token.value + } + + return "" +} diff --git a/actions-workflow-parser/src/templates/tokens/template-token.ts b/actions-workflow-parser/src/templates/tokens/template-token.ts new file mode 100644 index 0000000..33bf8eb --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/template-token.ts @@ -0,0 +1,246 @@ +import { + BooleanToken, + MappingToken, + NullToken, + NumberToken, + ScalarToken, + SequenceToken, + StringToken, +} from "." +import { Definition } from "../schema/definition" +import { DefinitionInfo } from "../schema/definition-info" +import { PropertyDefinition } from "../schema/property-definition" +import { SerializedToken } from "./serialization" +import { TokenRange } from "./token-range" +import { TraversalState } from "./traversal-state" +import { TokenType, tokenTypeName } from "./types" + +export class TemplateTokenError extends Error { + constructor(message: string, public readonly token?: TemplateToken) { + super(message) + } +} + +export abstract class TemplateToken { + // Fields for serialization + private readonly type: TokenType + public readonly file: number | undefined + public readonly range: TokenRange | undefined + public definitionInfo: DefinitionInfo | undefined + public propertyDefinition: PropertyDefinition | undefined + + /** + * Base class for all template tokens + */ + public constructor( + type: TokenType, + file: number | undefined, + range: TokenRange | undefined, + definitionInfo: DefinitionInfo | undefined + ) { + this.type = type + this.file = file + this.range = range + this.definitionInfo = definitionInfo + } + + public get templateTokenType(): number { + return this.type + } + + public get line(): number | undefined { + return this.range?.start[0] + } + + public get col(): number | undefined { + return this.range?.start[1] + } + + get description(): string | undefined { + return this.propertyDefinition?.description || this.definition?.description + } + + set description(description: string | undefined) { + if (this.propertyDefinition) { + this.propertyDefinition.description = description + } else if (this.definition) { + this.definition.description = description + } + } + + public get definition(): Definition | undefined { + return this.definitionInfo?.definition + } + + public abstract get isScalar(): boolean + + public abstract get isLiteral(): boolean + + public abstract get isExpression(): boolean + + public abstract clone(omitSource?: boolean): TemplateToken + + public typeName(): string { + return tokenTypeName(this.type) + } + + /** + * Asserts expected type and throws a good debug message if unexpected + */ + public assertNull(objectDescription: string): NullToken { + if (this.type === TokenType.Null) { + return this as unknown as NullToken + } + + throw new TemplateTokenError( + `Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName( + TokenType.Null + )}' was expected.`, + this + ) + } + + /** + * Asserts expected type and throws a good debug message if unexpected + */ + public assertBoolean(objectDescription: string): BooleanToken { + if (this.type === TokenType.Boolean) { + return this as unknown as BooleanToken + } + + throw new TemplateTokenError( + `Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName( + TokenType.Boolean + )}' was expected.`, + this + ) + } + + /** + * Asserts expected type and throws a good debug message if unexpected + */ + public assertNumber(objectDescription: string): NumberToken { + if (this.type === TokenType.Number) { + return this as unknown as NumberToken + } + + throw new TemplateTokenError( + `Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName( + TokenType.Number + )}' was expected.`, + this + ) + } + + /** + * Asserts expected type and throws a good debug message if unexpected + */ + public assertString(objectDescription: string): StringToken { + if (this.type === TokenType.String) { + return this as unknown as StringToken + } + + throw new TemplateTokenError( + `Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName( + TokenType.String + )}' was expected.`, + this + ) + } + + /** + * Asserts expected type and throws a good debug message if unexpected + */ + public assertScalar(objectDescription: string): ScalarToken { + if ((this as unknown as ScalarToken | undefined)?.isScalar === true) { + return this as unknown as ScalarToken + } + + throw new TemplateTokenError( + `Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type 'ScalarToken' was expected.`, + this + ) + } + + /** + * Asserts expected type and throws a good debug message if unexpected + */ + public assertSequence(objectDescription: string): SequenceToken { + if (this.type === TokenType.Sequence) { + return this as unknown as SequenceToken + } + + throw new TemplateTokenError( + `Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName( + TokenType.Sequence + )}' was expected.`, + this + ) + } + + /** + * Asserts expected type and throws a good debug message if unexpected + */ + public assertMapping(objectDescription: string): MappingToken { + if (this.type === TokenType.Mapping) { + return this as unknown as MappingToken + } + + throw new TemplateTokenError( + `Unexpected type '${this.typeName()}' encountered while reading '${objectDescription}'. The type '${tokenTypeName( + TokenType.Mapping + )}' was expected.`, + this + ) + } + + /** + * Returns all tokens (depth first) + * @param value The object to travese + * @param omitKeys Whether to omit mapping keys + */ + public static *traverse( + value: TemplateToken, + omitKeys?: boolean + ): Generator< + [ + parent: TemplateToken | undefined, + token: TemplateToken, + keyToken: TemplateToken | undefined + ], + void + > { + yield [undefined, value, undefined] + + switch (value.templateTokenType) { + case TokenType.Sequence: + case TokenType.Mapping: { + let state: TraversalState | undefined = new TraversalState( + undefined, + value + ) + state = new TraversalState(state, value) + while (state.parent) { + if (state.moveNext(omitKeys ?? false)) { + value = state.current as TemplateToken + yield [state.parent?.current, value, state.currentKey] + + switch (value.type) { + case TokenType.Sequence: + case TokenType.Mapping: + state = new TraversalState(state, value) + break + } + } else { + state = state.parent + } + } + break + } + } + } + + public toJSON(): SerializedToken { + return undefined + } +} diff --git a/actions-workflow-parser/src/templates/tokens/token-range.ts b/actions-workflow-parser/src/templates/tokens/token-range.ts new file mode 100644 index 0000000..137e983 --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/token-range.ts @@ -0,0 +1,6 @@ +export type Position = [line: number, character: number] + +export type TokenRange = { + start: Position + end: Position +} diff --git a/actions-workflow-parser/src/templates/tokens/traversal-state.ts b/actions-workflow-parser/src/templates/tokens/traversal-state.ts new file mode 100644 index 0000000..cf03729 --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/traversal-state.ts @@ -0,0 +1,71 @@ +import { TemplateToken } from "." +import { MappingToken } from "./mapping-token" +import { SequenceToken } from "./sequence-token" +import { TokenType } from "./types" + +export class TraversalState { + private readonly _token: TemplateToken + private index = -1 + private isKey = false + public readonly parent: TraversalState | undefined + public current: TemplateToken | undefined + public currentKey: TemplateToken | undefined + + public constructor(parent: TraversalState | undefined, token: TemplateToken) { + this.parent = parent + this._token = token + this.current = token + } + + public moveNext(omitKeys: boolean): boolean { + switch (this._token.templateTokenType) { + case TokenType.Sequence: { + const sequence = this._token as SequenceToken + if (++this.index < sequence.count) { + this.current = sequence.get(this.index) + return true + } + this.current = undefined + return false + } + + case TokenType.Mapping: { + const mapping = this._token as MappingToken + + // Already returned the key, now return the value + if (this.isKey) { + this.isKey = false + this.currentKey = this.current + this.current = mapping.get(this.index).value + return true + } + + // Move next + if (++this.index < mapping.count) { + // Skip the key, return the value + if (omitKeys) { + this.isKey = false + this.currentKey = mapping.get(this.index).key + this.current = mapping.get(this.index).value + return true + } + + // Return the key + this.isKey = true + this.currentKey = undefined + this.current = mapping.get(this.index).key + return true + } + + this.currentKey = undefined + this.current = undefined + return false + } + + default: + throw new Error( + `Unexpected token type '${this._token.templateTokenType}' when traversing state` + ) + } + } +} diff --git a/actions-workflow-parser/src/templates/tokens/type-guards.ts b/actions-workflow-parser/src/templates/tokens/type-guards.ts new file mode 100644 index 0000000..51603fc --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/type-guards.ts @@ -0,0 +1,42 @@ +import { BasicExpressionToken } from "./basic-expression-token" +import { BooleanToken } from "./boolean-token" +import { LiteralToken } from "./literal-token" +import { MappingToken } from "./mapping-token" +import { NumberToken } from "./number-token" +import { ScalarToken } from "./scalar-token" +import { SequenceToken } from "./sequence-token" +import { StringToken } from "./string-token" +import { TemplateToken } from "./template-token" +import { TokenType } from "./types" + +export function isLiteral(t: TemplateToken): t is LiteralToken { + return t.isLiteral +} + +export function isScalar(t: TemplateToken): t is ScalarToken { + return t.isScalar +} + +export function isString(t: TemplateToken): t is StringToken { + return isLiteral(t) && t.templateTokenType === TokenType.String +} + +export function isNumber(t: TemplateToken): t is NumberToken { + return isLiteral(t) && t.templateTokenType === TokenType.Number +} + +export function isBoolean(t: TemplateToken): t is BooleanToken { + return isLiteral(t) && t.templateTokenType === TokenType.Boolean +} + +export function isBasicExpression(t: TemplateToken): t is BasicExpressionToken { + return isScalar(t) && t.templateTokenType === TokenType.BasicExpression +} + +export function isSequence(t: TemplateToken): t is SequenceToken { + return t instanceof SequenceToken +} + +export function isMapping(t: TemplateToken): t is MappingToken { + return t instanceof MappingToken +} diff --git a/actions-workflow-parser/src/templates/tokens/types.ts b/actions-workflow-parser/src/templates/tokens/types.ts new file mode 100644 index 0000000..7b3b34e --- /dev/null +++ b/actions-workflow-parser/src/templates/tokens/types.ts @@ -0,0 +1,36 @@ +export enum TokenType { + String = 0, + Sequence, + Mapping, + BasicExpression, + InsertExpression, + Boolean, + Number, + Null, +} + +export function tokenTypeName(type: TokenType): string { + switch (type) { + case TokenType.String: + return "StringToken" + case TokenType.Sequence: + return "SequenceToken" + case TokenType.Mapping: + return "MappingToken" + case TokenType.BasicExpression: + return "BasicExpressionToken" + case TokenType.InsertExpression: + return "InsertExpressionToken" + case TokenType.Boolean: + return "BooleanToken" + case TokenType.Number: + return "NumberToken" + case TokenType.Null: + return "NullToken" + default: { + // Use never to ensure exhaustiveness + const exhaustiveCheck: never = type + throw new Error(`Unhandled token type: ${type} ${exhaustiveCheck}}`) + } + } +} diff --git a/actions-workflow-parser/src/templates/trace-writer.ts b/actions-workflow-parser/src/templates/trace-writer.ts new file mode 100644 index 0000000..42f3801 --- /dev/null +++ b/actions-workflow-parser/src/templates/trace-writer.ts @@ -0,0 +1,15 @@ +export interface TraceWriter { + error(message: string): void + + info(message: string): void + + verbose(message: string): void +} + +export class NoOperationTraceWriter implements TraceWriter { + public error(message: string): void {} + + public info(message: string): void {} + + public verbose(message: string): void {} +} diff --git a/actions-workflow-parser/src/test-utils/null-trace.ts b/actions-workflow-parser/src/test-utils/null-trace.ts new file mode 100644 index 0000000..0af9999 --- /dev/null +++ b/actions-workflow-parser/src/test-utils/null-trace.ts @@ -0,0 +1,7 @@ +import { TraceWriter } from "../templates/trace-writer" + +export const nullTrace: TraceWriter = { + info: (x) => {}, + verbose: (x) => {}, + error: (x) => {}, +} diff --git a/actions-workflow-parser/src/workflows/file.ts b/actions-workflow-parser/src/workflows/file.ts new file mode 100644 index 0000000..b3c98bb --- /dev/null +++ b/actions-workflow-parser/src/workflows/file.ts @@ -0,0 +1,4 @@ +export interface File { + name: string + content: string +} diff --git a/actions-workflow-parser/src/workflows/workflow-constants.ts b/actions-workflow-parser/src/workflows/workflow-constants.ts new file mode 100644 index 0000000..d9156c8 --- /dev/null +++ b/actions-workflow-parser/src/workflows/workflow-constants.ts @@ -0,0 +1,2 @@ +export const WORKFLOW_ROOT = "workflow-root-strict" +export const STRATEGY = "strategy" diff --git a/actions-workflow-parser/src/workflows/workflow-evaluator.ts b/actions-workflow-parser/src/workflows/workflow-evaluator.ts new file mode 100644 index 0000000..e36a7d9 --- /dev/null +++ b/actions-workflow-parser/src/workflows/workflow-evaluator.ts @@ -0,0 +1,51 @@ +import { Dictionary } from "@github/actions-expressions/data/dictionary" +import { + TemplateContext, + TemplateValidationErrors, +} from "../templates/template-context" +import * as templateEvaluator from "../templates/template-evaluator" +import { TemplateValidationError } from "../templates/template-validation-error" +import { TemplateToken } from "../templates/tokens/template-token" +import { TraceWriter } from "../templates/trace-writer" +import { STRATEGY } from "./workflow-constants" +import { getWorkflowSchema } from "./workflow-schema" + +export interface EvaluateWorkflowResult { + value: TemplateToken | undefined + errors: TemplateValidationError[] +} + +export function evaluateStrategy( + fileTable: string[], + context: Dictionary, + token: TemplateToken, + trace: TraceWriter +): EvaluateWorkflowResult { + const templateContext = new TemplateContext( + new TemplateValidationErrors(), + getWorkflowSchema(), + trace + ) + + // Add each file name + for (const fileName of fileTable) { + templateContext.getFileId(fileName) + } + + // Add expression named contexts + for (const pair of context.pairs()) { + templateContext.expressionNamedContexts.push(pair.key) + } + + const value = templateEvaluator.evaluateTemplate( + templateContext, + STRATEGY, + token, + 0, + undefined + ) + return { + value: value, + errors: templateContext.errors.getErrors(), + } +} diff --git a/actions-workflow-parser/src/workflows/workflow-parser.test.ts b/actions-workflow-parser/src/workflows/workflow-parser.test.ts new file mode 100644 index 0000000..ef0d950 --- /dev/null +++ b/actions-workflow-parser/src/workflows/workflow-parser.test.ts @@ -0,0 +1,22 @@ +import { parseWorkflow } from "./workflow-parser" +import { nullTrace } from "../test-utils/null-trace" + +it("The template is not read when there are YAML errors", () => { + const content = ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: 'Hello \${{ fromJSON('test') == inputs.name }}' + run: echo Hello, world!` + + const result = parseWorkflow( + "main.yaml", + [{ name: "main.yaml", content: content }], + nullTrace + ) + + expect(result.context.errors.count).toBe(1) + expect(result.value).toBeUndefined() +}) diff --git a/actions-workflow-parser/src/workflows/workflow-parser.ts b/actions-workflow-parser/src/workflows/workflow-parser.ts new file mode 100644 index 0000000..d25fecf --- /dev/null +++ b/actions-workflow-parser/src/workflows/workflow-parser.ts @@ -0,0 +1,52 @@ +import { + TemplateContext, + TemplateValidationErrors, +} from "../templates/template-context" +import * as templateReader from "../templates/template-reader" +import { TemplateToken } from "../templates/tokens/template-token" +import { TraceWriter } from "../templates/trace-writer" +import { File } from "./file" +import { WORKFLOW_ROOT } from "./workflow-constants" +import { getWorkflowSchema } from "./workflow-schema" +import { YamlObjectReader } from "./yaml-object-reader" +export interface ParseWorkflowResult { + context: TemplateContext + value: TemplateToken | undefined +} + +export function parseWorkflow( + entryFileName: string, + files: File[], + trace: TraceWriter +): ParseWorkflowResult { + const context = new TemplateContext( + new TemplateValidationErrors(), + getWorkflowSchema(), + trace + ) + + // Add file ids + files.forEach((x) => context.getFileId(x.name)) + + const fileId = context.getFileId(entryFileName) + const fileContent = files[fileId - 1].content + const reader = new YamlObjectReader(context, fileId, fileContent) + if (context.errors.count > 0) { + // The file is not valid YAML, template errors could be misleading + return { + context, + value: undefined, + } + } + const result = templateReader.readTemplate( + context, + WORKFLOW_ROOT, + reader, + fileId + ) + + return { + context, + value: result, + } +} diff --git a/actions-workflow-parser/src/workflows/workflow-schema.ts b/actions-workflow-parser/src/workflows/workflow-schema.ts new file mode 100644 index 0000000..9e8148e --- /dev/null +++ b/actions-workflow-parser/src/workflows/workflow-schema.ts @@ -0,0 +1,13 @@ +import { JSONObjectReader } from "../templates/json-object-reader" +import { TemplateSchema } from "../templates/schema" +import WorkflowSchema from "./workflow-v1.0.json" + +let schema: TemplateSchema + +export function getWorkflowSchema(): TemplateSchema { + if (schema === undefined) { + const json = JSON.stringify(WorkflowSchema) + schema = TemplateSchema.load(new JSONObjectReader(undefined, json)) + } + return schema +} diff --git a/actions-workflow-parser/src/workflows/workflow-v1.0.json b/actions-workflow-parser/src/workflows/workflow-v1.0.json new file mode 100644 index 0000000..9613bd7 --- /dev/null +++ b/actions-workflow-parser/src/workflows/workflow-v1.0.json @@ -0,0 +1,2466 @@ +{ + "version": "workflow-v1.0", + "definitions": { + "workflow-root": { + "description": "A workflow file.", + "mapping": { + "properties": { + "on": "on", + "name": "workflow-name", + "run-name": "run-name", + "defaults": "workflow-defaults", + "env": "workflow-env", + "permissions": "permissions", + "concurrency": "workflow-concurrency", + "jobs": { + "type": "jobs", + "required": true + } + } + } + }, + "workflow-root-strict": { + "description": "Workflow file with strict validation", + "mapping": { + "properties": { + "on": { + "type": "on-strict", + "required": true + }, + "name": "workflow-name", + "run-name": "run-name", + "defaults": "workflow-defaults", + "env": "workflow-env", + "permissions": "permissions", + "concurrency": "workflow-concurrency", + "jobs": { + "type": "jobs", + "required": true + } + } + } + }, + "workflow-name": { + "description": "The name of the workflow.", + "string": {} + }, + "run-name": { + "context": [ + "github", + "inputs", + "vars" + ], + "string": {}, + "description": "The name for workflow runs generated from the workflow. GitHub displays the workflow run name in the list of workflow runs on your repository's 'Actions' tab." + }, + "on": { + "description": "The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows.", + "one-of": [ + "string", + "sequence", + "on-mapping" + ] + }, + "on-mapping": { + "mapping": { + "properties": { + "workflow_call": "workflow-call" + }, + "loose-key-type": "non-empty-string", + "loose-value-type": "any" + } + }, + "on-strict": { + "description": "The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows.", + "one-of": [ + "on-string-strict", + "on-sequence-strict", + "on-mapping-strict" + ] + }, + "on-mapping-strict": { + "description": "The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows.", + "mapping": { + "properties": { + "branch_protection_rule": "branch-protection-rule", + "check_run": "check-run", + "check_suite": "check-suite", + "create": "create", + "delete": "delete", + "deployment": "deployment", + "deployment_status": "deployment-status", + "discussion": "discussion", + "discussion_comment": "discussion-comment", + "fork": "fork", + "gollum": "gollum", + "issue_comment": "issue-comment", + "issues": "issues", + "label": "label", + "merge_group": "merge-group", + "milestone": "milestone", + "page_build": "page-build", + "project": "project", + "project_card": "project-card", + "project_column": "project-column", + "public": "public", + "pull_request": "pull-request", + "pull_request_comment": "pull-request-comment", + "pull_request_review": "pull-request-review", + "pull_request_review_comment": "pull-request-review-comment", + "pull_request_target": "pull-request-target", + "push": "push", + "registry_package": "registry-package", + "release": "release", + "repository_dispatch": "repository-dispatch", + "schedule": "schedule", + "status": "status", + "watch": "watch", + "workflow_call": "workflow-call", + "workflow_dispatch": "workflow-dispatch", + "workflow_run": "workflow-run" + } + } + }, + "on-string-strict": { + "one-of": [ + "branch-protection-rule-string", + "check-run-string", + "check-suite-string", + "create-string", + "delete-string", + "deployment-string", + "deployment-status-string", + "discussion-string", + "discussion-comment-string", + "fork-string", + "gollum-string", + "issue-comment-string", + "issues-string", + "label-string", + "merge-group-string", + "milestone-string", + "page-build-string", + "project-string", + "project-card-string", + "project-column-string", + "public-string", + "pull-request-string", + "pull-request-comment-string", + "pull-request-review-string", + "pull-request-review-comment-string", + "pull-request-target-string", + "push-string", + "registry-package-string", + "release-string", + "repository-dispatch-string", + "schedule-string", + "status-string", + "watch-string", + "workflow-call-string", + "workflow-dispatch-string", + "workflow-run-string" + ] + }, + "on-sequence-strict": { + "sequence": { + "item-type": "on-string-strict" + } + }, + "branch-protection-rule-string": { + "description": "Runs your workflow when branch protection rules in the workflow repository are changed.", + "string": { + "constant": "branch_protection_rule" + } + }, + "branch-protection-rule": { + "description": "Runs your workflow when branch protection rules in the workflow repository are changed.", + "one-of": [ + "null", + "branch-protection-rule-mapping" + ] + }, + "branch-protection-rule-mapping": { + "mapping": { + "properties": { + "types": "branch-protection-rule-activity" + } + } + }, + "branch-protection-rule-activity": { + "description": "The types of branch protection rule activity that trigger the workflow. Can be one or more of: created, edited, deleted.", + "one-of": [ + "branch-protection-rule-activity-type", + "branch-protection-rule-activity-types" + ] + }, + "branch-protection-rule-activity-types": { + "sequence": { + "item-type": "branch-protection-rule-activity-type" + } + }, + "branch-protection-rule-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "check-run-string": { + "description": "Runs your workflow when activity related to a check run occurs. A check run is an individual test that is part of a check suite.", + "string": { + "constant": "check_run" + } + }, + "check-run": { + "description": "Runs your workflow when activity related to a check run occurs. A check run is an individual test that is part of a check suite.", + "one-of": [ + "null", + "check-run-mapping" + ] + }, + "check-run-mapping": { + "mapping": { + "properties": { + "types": "check-run-activity" + } + } + }, + "check-run-activity": { + "description": "The types of check run activity that trigger the workflow. Can be one or more of: completed, requested, rerequested, or requested-action.", + "one-of": [ + "check-run-activity-type", + "check-run-activity-types" + ] + }, + "check-run-activity-types": { + "sequence": { + "item-type": "check-run-activity-type" + } + }, + "check-run-activity-type": { + "allowed-values": [ + "completed", + "created", + "rerequested", + "requested_action" + ] + }, + "check-suite-string": { + "description": "Runs your workflow when check suite activity occurs. A check suite is a collection of the check runs created for a specific commit. Check suites summarize the status and conclusion of the check runs that are in the suite.", + "string": { + "constant": "check_suite" + } + }, + "check-suite": { + "description": "Runs your workflow when check suite activity occurs. A check suite is a collection of the check runs created for a specific commit. Check suites summarize the status and conclusion of the check runs that are in the suite.", + "one-of": [ + "null", + "check-suite-mapping" + ] + }, + "check-suite-mapping": { + "mapping": { + "properties": { + "types": "check-suite-activity" + } + } + }, + "check-suite-activity": { + "description": "The types of check suite activity that trigger the workflow. Currently only completed is supported.", + "one-of": [ + "check-suite-activity-type", + "check-suite-activity-types" + ] + }, + "check-suite-activity-types": { + "sequence": { + "item-type": "check-suite-activity-type" + } + }, + "check-suite-activity-type": { + "allowed-values": [ + "completed" + ] + }, + "create-string": { + "description": "Runs your workflow when someone creates a Git reference (Git branch or tag) in the workflow's repository.", + "string": { + "constant": "create" + } + }, + "create": { + "description": "Runs your workflow when someone creates a Git reference (Git branch or tag) in the workflow's repository.", + "null": {} + }, + "delete-string": { + "description": "Runs your workflow when someone deletes a Git reference (Git branch or tag) in the workflow's repository.", + "string": { + "constant": "delete" + } + }, + "delete": { + "description": "Runs your workflow when someone deletes a Git reference (Git branch or tag) in the workflow's repository.", + "null": {} + }, + "deployment-string": { + "description": "Runs your workflow when someone creates a deployment in the workflow's repository. Deployments created with a commit SHA may not have a Git ref.", + "string": { + "constant": "deployment" + } + }, + "deployment": { + "description": "Runs your workflow when someone creates a deployment in the workflow's repository. Deployments created with a commit SHA may not have a Git ref.", + "null": {} + }, + "deployment-status-string": { + "description": "Runs your workflow when a third party provides a deployment status. Deployments created with a commit SHA may not have a Git ref.", + "string": { + "constant": "deployment_status" + } + }, + "deployment-status": { + "description": "Runs your workflow when a third party provides a deployment status. Deployments created with a commit SHA may not have a Git ref.", + "null": {} + }, + "discussion-string": { + "description": "Runs your workflow when a discussion in the workflow's repository is created or modified. For activity related to comments on a discussion, use the discussion_comment event.", + "string": { + "constant": "discussion" + } + }, + "discussion": { + "description": "Runs your workflow when a discussion in the workflow's repository is created or modified. For activity related to comments on a discussion, use the discussion_comment event.", + "one-of": [ + "null", + "discussion-mapping" + ] + }, + "discussion-mapping": { + "mapping": { + "properties": { + "types": "discussion-activity" + } + } + }, + "discussion-activity": { + "description": "The types of discussion activity that trigger the workflow. Can be one or more of: created, edited, deleted, transferred, pinned, unpinned, labeled, unlabeled, locked, unlocked, category_changed, answered, or unanswered.", + "one-of": [ + "discussion-activity-type", + "discussion-activity-types" + ] + }, + "discussion-activity-types": { + "sequence": { + "item-type": "discussion-activity-type" + } + }, + "discussion-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted", + "transferred", + "pinned", + "unpinned", + "labeled", + "unlabeled", + "locked", + "unlocked", + "category_changed", + "answered", + "unanswered" + ] + }, + "discussion-comment-string": { + "description": "Runs your workflow when a comment on a discussion in the workflow's repository is created or modified. For activity related to a discussion as opposed to comments on the discussion, use the discussion event.", + "string": { + "constant": "discussion_comment" + } + }, + "discussion-comment": { + "description": "Runs your workflow when a comment on a discussion in the workflow's repository is created or modified. For activity related to a discussion as opposed to comments on the discussion, use the discussion event.", + "one-of": [ + "null", + "discussion-comment-mapping" + ] + }, + "discussion-comment-mapping": { + "mapping": { + "properties": { + "types": "discussion-comment-activity" + } + } + }, + "discussion-comment-activity": { + "description": "The types of discussion comment activity that trigger the workflow. Can be one or more of: created, edited, or deleted.", + "one-of": [ + "discussion-comment-activity-type", + "discussion-comment-activity-types" + ] + }, + "discussion-comment-activity-types": { + "sequence": { + "item-type": "discussion-comment-activity-type" + } + }, + "discussion-comment-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "fork-string": { + "description": "Runs your workflow when someone forks a repository.", + "string": { + "constant": "fork" + } + }, + "fork": { + "description": "Runs your workflow when someone forks a repository.", + "null": {} + }, + "gollum-string": { + "description": "Runs your workflow when someone creates or updates a Wiki page.", + "string": { + "constant": "gollum" + } + }, + "gollum": { + "description": "Runs your workflow when someone creates or updates a Wiki page.", + "null": {} + }, + "issue-comment-string": { + "description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.", + "string": { + "constant": "issue_comment" + } + }, + "issue-comment": { + "description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.", + "one-of": [ + "null", + "issue-comment-mapping" + ] + }, + "issue-comment-mapping": { + "mapping": { + "properties": { + "types": "issue-comment-activity" + } + } + }, + "issue-comment-activity": { + "description": "The types of issue comment activity that trigger the workflow. Can be one or more of: created, edited, or deleted.", + "one-of": [ + "issue-comment-activity-type", + "issue-comment-activity-types" + ] + }, + "issue-comment-activity-types": { + "sequence": { + "item-type": "issue-comment-activity-type" + } + }, + "issue-comment-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "issues-string": { + "description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.", + "string": { + "constant": "issues" + } + }, + "issues": { + "description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.", + "one-of": [ + "null", + "issues-mapping" + ] + }, + "issues-mapping": { + "mapping": { + "properties": { + "types": "issues-activity" + } + } + }, + "issues-activity": { + "description": "The types of issue activity that trigger the workflow. Can be one or more of: opened, edited, deleted, transferred, pinned, unpinned, closed, reopened, assigned, unassigned, labeled, unlabeled, locked, unlocked, milestoned, or demilestoned.", + "one-of": [ + "issues-activity-type", + "issues-activity-types" + ] + }, + "issues-activity-types": { + "sequence": { + "item-type": "issues-activity-type" + } + }, + "issues-activity-type": { + "allowed-values": [ + "opened", + "edited", + "deleted", + "transferred", + "pinned", + "unpinned", + "closed", + "reopened", + "assigned", + "unassigned", + "labeled", + "unlabeled", + "locked", + "unlocked", + "milestoned", + "demilestoned" + ] + }, + "label-string": { + "description": "Runs your workflow when a label in your workflow's repository is created or modified.", + "string": { + "constant": "label" + } + }, + "label": { + "description": "Runs your workflow when a label in your workflow's repository is created or modified.", + "one-of": [ + "null", + "label-mapping" + ] + }, + "label-mapping": { + "mapping": { + "properties": { + "types": "label-activity" + } + } + }, + "label-activity": { + "description": "The types of label activity that trigger the workflow. Can be one or more of: created, edited, or deleted.", + "one-of": [ + "label-activity-type", + "label-activity-types" + ] + }, + "label-activity-types": { + "sequence": { + "item-type": "label-activity-type" + } + }, + "label-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "merge-group-string": { + "description": "Runs your workflow when a pull request is added to a merge queue, which adds the pull request to a merge group.", + "string": { + "constant": "merge_group" + } + }, + "merge-group": { + "description": "Runs your workflow when a pull request is added to a merge queue, which adds the pull request to a merge group.", + "one-of": [ + "null", + "merge-group-mapping" + ] + }, + "merge-group-mapping": { + "mapping": { + "properties": { + "types": "merge-group-activity" + } + } + }, + "merge-group-activity": { + "description": "The types of label activity that trigger the workflow. Currently only checks_requested is supported.", + "one-of": [ + "merge-group-activity-type", + "merge-group-activity-types" + ] + }, + "merge-group-activity-types": { + "sequence": { + "item-type": "merge-group-activity-type" + } + }, + "merge-group-activity-type": { + "allowed-values": [ + "checks_requested" + ] + }, + "milestone-string": { + "description": "Runs your workflow when a milestone in the workflow's repository is created or modified.", + "string": { + "constant": "milestone" + } + }, + "milestone": { + "description": "Runs your workflow when a milestone in the workflow's repository is created or modified.", + "one-of": [ + "null", + "milestone-mapping" + ] + }, + "milestone-mapping": { + "mapping": { + "properties": { + "types": "milestone-activity" + } + } + }, + "milestone-activity": { + "description": "The types of milestone activity that trigger the workflow. Can be one or more of: created, edited, opened, closed, or deleted.", + "one-of": [ + "milestone-activity-type", + "milestone-activity-types" + ] + }, + "milestone-activity-types": { + "sequence": { + "item-type": "milestone-activity-type" + } + }, + "milestone-activity-type": { + "allowed-values": [ + "created", + "closed", + "opened", + "edited", + "deleted" + ] + }, + "page-build-string": { + "description": "Runs your workflow when someone pushes to a branch that is the publishing source for GitHub Pages, if GitHub Pages is enabled for the repository.", + "string": { + "constant": "page_build" + } + }, + "page-build": { + "description": "Runs your workflow when someone pushes to a branch that is the publishing source for GitHub Pages, if GitHub Pages is enabled for the repository.", + "null": {} + }, + "project-string": { + "description": "Runs your workflow when a project board is created or modified. For activity related to cards or columns in a project board, use the project_card or project_column events instead.", + "string": { + "constant": "project" + } + }, + "project": { + "description": "Runs your workflow when a project board is created or modified. For activity related to cards or columns in a project board, use the project_card or project_column events instead.", + "one-of": [ + "null", + "project-mapping" + ] + }, + "project-mapping": { + "mapping": { + "properties": { + "types": "project-activity" + } + } + }, + "project-activity": { + "description": "The types of project activity that trigger the workflow. Can be one or more of: created, edited, opened, closed, or deleted.", + "one-of": [ + "project-activity-type", + "project-activity-types" + ] + }, + "project-activity-types": { + "sequence": { + "item-type": "project-activity-type" + } + }, + "project-activity-type": { + "allowed-values": [ + "created", + "closed", + "opened", + "edited", + "deleted" + ] + }, + "project-card-string": { + "description": "Runs your workflow when a card on a project board is created or modified. For activity related to project boards or columns in a project board, use the project or project_column event instead.", + "string": { + "constant": "project_card" + } + }, + "project-card": { + "description": "Runs your workflow when a card on a project board is created or modified. For activity related to project boards or columns in a project board, use the project or project_column event instead.", + "one-of": [ + "null", + "project-card-mapping" + ] + }, + "project-card-mapping": { + "mapping": { + "properties": { + "types": "project-card-activity" + } + } + }, + "project-card-activity": { + "description": "The types of project card activity that trigger the workflow. Can be one or more of: created, edited, moved, converted, or deleted.", + "one-of": [ + "project-card-activity-type", + "project-card-activity-types" + ] + }, + "project-card-activity-types": { + "sequence": { + "item-type": "project-card-activity-type" + } + }, + "project-card-activity-type": { + "allowed-values": [ + "created", + "moved", + "converted", + "edited", + "deleted" + ] + }, + "project-column-string": { + "description": "Runs your workflow when a column on a project board is created or modified. For activity related to project boards or cards in a project board, use the project or project_card event instead.", + "string": { + "constant": "project_column" + } + }, + "project-column": { + "description": "Runs your workflow when a column on a project board is created or modified. For activity related to project boards or cards in a project board, use the project or project_card event instead.", + "one-of": [ + "null", + "project-column-mapping" + ] + }, + "project-column-mapping": { + "mapping": { + "properties": { + "types": "project-column-activity" + } + } + }, + "project-column-activity": { + "description": "The types of project column activity that trigger the workflow. Can be one or more of: created, updated, moved, or deleted.", + "one-of": [ + "project-column-activity-type", + "project-column-activity-types" + ] + }, + "project-column-activity-types": { + "sequence": { + "item-type": "project-column-activity-type" + } + }, + "project-column-activity-type": { + "allowed-values": [ + "created", + "updated", + "moved", + "deleted" + ] + }, + "public-string": { + "description": "Runs your workflow when your workflow's repository changes from private to public.", + "string": { + "constant": "public" + } + }, + "public": { + "description": "Runs your workflow when your workflow's repository changes from private to public.", + "null": {} + }, + "pull-request-string": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. For example, if no activity types are specified, the workflow runs when a pull request is opened or reopened or when the head branch of the pull request is updated. For activity related to pull request reviews, pull request review comments, or pull request comments, use the pull_request_review, pull_request_review_comment, or issue_comment events instead.", + "string": { + "constant": "pull_request" + } + }, + "pull-request": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. For example, if no activity types are specified, the workflow runs when a pull request is opened or reopened or when the head branch of the pull request is updated. For activity related to pull request reviews, pull request review comments, or pull request comments, use the pull_request_review, pull_request_review_comment, or issue_comment events instead.", + "one-of": [ + "null", + "pull-request-mapping" + ] + }, + "pull-request-mapping": { + "mapping": { + "properties": { + "types": "pull-request-activity", + "branches": "event-branches", + "branches-ignore": "event-branches-ignore", + "paths": "event-paths", + "paths-ignore": "event-paths-ignore" + } + } + }, + "pull-request-activity": { + "description": "The types of pull request activity that trigger the workflow. Can be one or more of: created, edited, opened, closed, or deleted.", + "one-of": [ + "pull-request-activity-type", + "pull-request-activity-types" + ] + }, + "pull-request-activity-types": { + "sequence": { + "item-type": "pull-request-activity-type" + } + }, + "pull-request-activity-type": { + "allowed-values": [ + "assigned", + "unassigned", + "labeled", + "unlabeled", + "opened", + "edited", + "closed", + "reopened", + "synchronize", + "converted_to_draft", + "ready_for_review", + "locked", + "unlocked", + "review_requested", + "review_request_removed", + "auto_merge_enabled", + "auto_merge_disabled" + ] + }, + "pull-request-comment-string": { + "description": "Please use the issue_comment event instead.", + "string": { + "constant": "pull_request_comment" + } + }, + "pull-request-comment": { + "description": "Please use the issue_comment event instead.", + "one-of": [ + "null", + "issue-comment-mapping" + ] + }, + "pull-request-review-string": { + "description": "Runs your workflow when a pull request review is submitted, edited, or dismissed. A pull request review is a group of pull request review comments in addition to a body comment and a state. For activity related to pull request review comments or pull request comments, use the pull_request_review_comment or issue_comment events instead.", + "string": { + "constant": "pull_request_review" + } + }, + "pull-request-review": { + "description": "Runs your workflow when a pull request review is submitted, edited, or dismissed. A pull request review is a group of pull request review comments in addition to a body comment and a state. For activity related to pull request review comments or pull request comments, use the pull_request_review_comment or issue_comment events instead.", + "one-of": [ + "null", + "pull-request-review-mapping" + ] + }, + "pull-request-review-mapping": { + "mapping": { + "properties": { + "types": "pull-request-review-activity" + } + } + }, + "pull-request-review-activity": { + "description": "The types of pull request review activity that trigger the workflow. Can be one or more of: submitted, edited, or dismissed.", + "one-of": [ + "pull-request-review-activity-type", + "pull-request-review-activity-types" + ] + }, + "pull-request-review-activity-types": { + "sequence": { + "item-type": "pull-request-review-activity-type" + } + }, + "pull-request-review-activity-type": { + "allowed-values": [ + "submitted", + "edited", + "dismissed" + ] + }, + "pull-request-review-comment-string": { + "description": "", + "string": { + "constant": "pull_request_review_comment" + } + }, + "pull-request-review-comment": { + "description": "", + "one-of": [ + "null", + "pull-request-review-comment-mapping" + ] + }, + "pull-request-review-comment-mapping": { + "mapping": { + "properties": { + "types": "pull-request-review-comment-activity" + } + } + }, + "pull-request-review-comment-activity": { + "description": "The types of pull request review comment activity that trigger the workflow. Can be one or more of: created, edited, or deleted.", + "one-of": [ + "pull-request-review-comment-activity-type", + "pull-request-review-comment-activity-types" + ] + }, + "pull-request-review-comment-activity-types": { + "sequence": { + "item-type": "pull-request-review-comment-activity-type" + } + }, + "pull-request-review-comment-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "pull-request-target-string": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. For example, if no activity types are specified, the workflow runs when a pull request is opened or reopened or when the head branch of the pull request is updated. This event runs in the context of the base of the pull request, rather than in the context of the merge commit, as the pull_request event does. This prevents execution of unsafe code from the head of the pull request that could alter your repository or steal any secrets you use in your workflow. This event allows your workflow to do things like label or comment on pull requests from forks. Avoid using this event if you need to build or run code from the pull request.", + "string": { + "constant": "pull_request_target" + } + }, + "pull-request-target": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. For example, if no activity types are specified, the workflow runs when a pull request is opened or reopened or when the head branch of the pull request is updated. This event runs in the context of the base of the pull request, rather than in the context of the merge commit, as the pull_request event does. This prevents execution of unsafe code from the head of the pull request that could alter your repository or steal any secrets you use in your workflow. This event allows your workflow to do things like label or comment on pull requests from forks. Avoid using this event if you need to build or run code from the pull request.", + "one-of": [ + "null", + "pull-request-target-mapping" + ] + }, + "pull-request-target-mapping": { + "mapping": { + "properties": { + "types": "pull-request-target-activity", + "branches": "event-branches", + "branches-ignore": "event-branches-ignore", + "paths": "event-paths", + "paths-ignore": "event-paths-ignore" + } + } + }, + "pull-request-target-activity": { + "description": "The types of pull request activity that trigger the workflow. Can be one or more of: assigned, unassigned, labeled, unlabeled, opened, edited, closed, reopened, synchronize, converted_to_draft, ready_for_review, locked, unlocked, review_requested, review_request_removed, auto_merge_enabled, or auto_merge_disabled.", + "one-of": [ + "pull-request-target-activity-type", + "pull-request-target-activity-types" + ] + }, + "pull-request-target-activity-types": { + "sequence": { + "item-type": "pull-request-target-activity-type" + } + }, + "pull-request-target-activity-type": { + "allowed-values": [ + "assigned", + "unassigned", + "labeled", + "unlabeled", + "opened", + "edited", + "closed", + "reopened", + "synchronize", + "converted_to_draft", + "ready_for_review", + "locked", + "unlocked", + "review_requested", + "review_request_removed", + "auto_merge_enabled", + "auto_merge_disabled" + ] + }, + "push-string": { + "description": "Runs your workflow when you push a commit or tag.", + "string": { + "constant": "push" + } + }, + "push": { + "description": "Runs your workflow when you push a commit or tag.", + "one-of": [ + "null", + "push-mapping" + ] + }, + "push-mapping": { + "mapping": { + "properties": { + "branches": "event-branches", + "branches-ignore": "event-branches-ignore", + "tags": "event-tags", + "tags-ignore": "event-tags-ignore", + "paths": "event-paths", + "paths-ignore": "event-paths-ignore" + } + } + }, + "registry-package-string": { + "description": "Runs your workflow when activity related to GitHub Packages occurs in your repository.", + "string": { + "constant": "registry_package" + } + }, + "registry-package": { + "description": "Runs your workflow when activity related to GitHub Packages occurs in your repository.", + "one-of": [ + "null", + "registry-package-mapping" + ] + }, + "registry-package-mapping": { + "mapping": { + "properties": { + "types": "registry-package-activity" + } + } + }, + "registry-package-activity": { + "description": "The types of registry package activity that trigger the workflow. Can be one or more of: published or updated.", + "one-of": [ + "registry-package-activity-type", + "registry-package-activity-types" + ] + }, + "registry-package-activity-types": { + "sequence": { + "item-type": "registry-package-activity-type" + } + }, + "registry-package-activity-type": { + "allowed-values": [ + "published", + "updated" + ] + }, + "release-string": { + "description": "Runs your workflow when release activity in your repository occurs.", + "string": { + "constant": "release" + } + }, + "release": { + "description": "Runs your workflow when release activity in your repository occurs.", + "one-of": [ + "null", + "release-mapping" + ] + }, + "release-mapping": { + "mapping": { + "properties": { + "types": "release-activity" + } + } + }, + "release-activity": { + "description": "The types of release activity that trigger the workflow. Can be one or more of: published, unpublished, created, edited, deleted, prereleased, or released.", + "one-of": [ + "release-activity-type", + "release-activity-types" + ] + }, + "release-activity-types": { + "sequence": { + "item-type": "release-activity-type" + } + }, + "release-activity-type": { + "allowed-values": [ + "published", + "unpublished", + "created", + "edited", + "deleted", + "prereleased", + "released" + ] + }, + "schedule-string": { + "description": "The schedule event allows you to trigger a workflow at a scheduled time. You can schedule a workflow to run at specific UTC times using POSIX cron syntax. Scheduled workflows run on the latest commit on the default or base branch. The shortest interval you can run scheduled workflows is once every 5 minutes. GitHub Actions does not support the non-standard syntax @yearly, @monthly, @weekly, @daily, @hourly, and @reboot.", + "string": { + "constant": "schedule" + } + }, + "schedule": { + "description": "The schedule event allows you to trigger a workflow at a scheduled time. You can schedule a workflow to run at specific UTC times using POSIX cron syntax. Scheduled workflows run on the latest commit on the default or base branch. The shortest interval you can run scheduled workflows is once every 5 minutes. GitHub Actions does not support the non-standard syntax @yearly, @monthly, @weekly, @daily, @hourly, and @reboot.", + "sequence": { + "item-type": "cron-mapping" + } + }, + "status-string": { + "description": "Runs your workflow when the status of a Git commit changes. For example, commits can be marked as error, failure, pending, or success. If you want to provide more details about the status change, you may want to use the check_run event.", + "string": { + "constant": "status" + } + }, + "status": { + "description": "Runs your workflow when the status of a Git commit changes. For example, commits can be marked as error, failure, pending, or success. If you want to provide more details about the status change, you may want to use the check_run event.", + "null": {} + }, + "watch-string": { + "description": "Runs your workflow when the workflow's repository is starred.", + "string": { + "constant": "watch" + } + }, + "watch": { + "description": "Runs your workflow when the workflow's repository is starred.", + "one-of": [ + "null", + "watch-mapping" + ] + }, + "watch-mapping": { + "mapping": { + "properties": { + "types": "watch-activity" + } + } + }, + "watch-activity": { + "description": "The types of watch activity that trigger the workflow. Currently, the only supported type is started.", + "one-of": [ + "watch-activity-type", + "watch-activity-types" + ] + }, + "watch-activity-types": { + "sequence": { + "item-type": "watch-activity-type" + } + }, + "watch-activity-type": { + "allowed-values": [ + "started" + ] + }, + "workflow-run-string": { + "description": "This event occurs when a workflow run is requested or completed. It allows you to execute a workflow based on execution or completion of another workflow. The workflow started by the workflow_run event is able to access secrets and write tokens, even if the previous workflow was not. This is useful in cases where the previous workflow is intentionally not privileged, but you need to take a privileged action in a later workflow.", + "string": { + "constant": "workflow_run" + } + }, + "workflow-run": { + "description": "This event occurs when a workflow run is requested or completed. It allows you to execute a workflow based on execution or completion of another workflow. The workflow started by the workflow_run event is able to access secrets and write tokens, even if the previous workflow was not. This is useful in cases where the previous workflow is intentionally not privileged, but you need to take a privileged action in a later workflow.", + "one-of": [ + "null", + "workflow-run-mapping" + ] + }, + "workflow-run-mapping": { + "mapping": { + "properties": { + "types": "workflow-run-activity", + "workflows": "workflow-run-workflows", + "branches": "event-branches", + "branches-ignore": "event-branches-ignore" + } + } + }, + "workflow-run-workflows": { + "description": "The name of the workflow that triggers the workflow_run event. The workflow must be in the same repository as the workflow that uses the workflow_run event.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "workflow-run-activity": { + "description": "The types of workflow run activity that trigger the workflow. Can be one or more of: requested or completed.", + "one-of": [ + "workflow-run-activity-type", + "workflow-run-activity-types" + ] + }, + "workflow-run-activity-types": { + "sequence": { + "item-type": "workflow-run-activity-type" + } + }, + "workflow-run-activity-type": { + "allowed-values": [ + "requested", + "completed", + "in_progress" + ] + }, + "event-branches": { + "description": "Use the branches filter to configure your workflow to only run when an event occurs on specific branches. If you use the branches filter and other filters (such as branches-ignore), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-branches-ignore": { + "description": "You can use the branches-ignore filter to configure your workflow to not run when an event occurs on specific branches. If you use the branches-ignore filter and other filters (such as branches), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-tags": { + "description": "Use the tags filter to configure your workflow to only run when an event occurs on specific tags. If you use the tags filter and other filters (such as tags-ignore), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-tags-ignore": { + "description": "You can use the tags-ignore filter to configure your workflow to not run when an event occurs on specific tags. If you use the tags-ignore filter and other filters (such as tags), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-paths": { + "description": "Configure your workflow to only run when an event changes specific files. If you use both the paths filter and other filters (such as paths-ignore), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-paths-ignore": { + "description": "You can use the paths-ignore filter to configure your workflow to not run when an event changes specific files. If you use both the paths-ignore filter and other filters (such as paths), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "repository-dispatch-string": { + "description": "You can use the GitHub API to trigger a webhook event called repository_dispatch when you want to trigger a workflow for activity that happens outside of GitHub. For more information, see https://developer.github.com/v3/repos/#create-a-repository-dispatch-event. To trigger the custom repository_dispatch webhook event, you must send a POST request to a GitHub API endpoint and provide an event_type name to describe the activity type. To trigger a workflow run, you must also configure your workflow to use the repository_dispatch event.", + "string": { + "constant": "branch_protection_rule" + } + }, + "repository-dispatch": { + "description": "You can use the GitHub API to trigger a webhook event called repository_dispatch when you want to trigger a workflow for activity that happens outside of GitHub. For more information, see https://developer.github.com/v3/repos/#create-a-repository-dispatch-event. To trigger the custom repository_dispatch webhook event, you must send a POST request to a GitHub API endpoint and provide an event_type name to describe the activity type. To trigger a workflow run, you must also configure your workflow to use the repository_dispatch event.", + "one-of": [ + "null", + "repository-dispatch-mapping" + ] + }, + "repository-dispatch-mapping": { + "mapping": { + "properties": { + "types": "sequence-of-non-empty-string" + } + } + }, + "workflow-call-string": { + "description": "Allows workflows to be reused by other workflows.", + "string": { + "constant": "workflow_call" + } + }, + "workflow-call": { + "description": "Allows workflows to be reused by other workflows.", + "one-of": [ + "null", + "workflow-call-mapping" + ] + }, + "workflow-call-mapping": { + "mapping": { + "properties": { + "inputs": "workflow-call-inputs", + "secrets": "workflow-call-secrets", + "outputs": "workflow-call-outputs" + } + } + }, + "workflow-call-inputs": { + "description": "When using the workflow_call keyword, you can optionally specify inputs that are passed to the called workflow from the caller workflow.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "workflow-call-input-definition" + } + }, + "workflow-call-input-definition": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the input parameter." + }, + "type": { + "type": "workflow-call-input-type", + "required": true + }, + "required": { + "type": "boolean", + "description": "A boolean to indicate whether the action requires the input parameter. Set to true when the parameter is required." + }, + "default": "workflow-call-input-default" + } + } + }, + "workflow-call-input-type": { + "description": "Required if input is defined for the on.workflow_call keyword. The value of this parameter is a string specifying the data type of the input. This must be one of: boolean, number, or string.", + "one-of": [ + "input-type-string", + "input-type-boolean", + "input-type-number" + ] + }, + "input-type-string": { + "string": { + "constant": "string" + } + }, + "input-type-boolean": { + "string": { + "constant": "boolean" + } + }, + "input-type-number": { + "string": { + "constant": "number" + } + }, + "input-type-choice": { + "string": { + "constant": "choice" + } + }, + "input-type-environment": { + "string": { + "constant": "environment" + } + }, + "workflow-call-input-default": { + "description": "The default value is used when an input parameter isn't specified in a workflow file.", + "context": [ + "github", + "inputs", + "vars" + ], + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "workflow-call-secrets": { + "description": "A map of the secrets that can be used in the called workflow. Within the called workflow, you can use the secrets context to refer to a secret.", + "mapping": { + "loose-key-type": "workflow-call-secret-name", + "loose-value-type": "workflow-call-secret-definition" + } + }, + "workflow-call-secret-name": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the secret." + }, + "workflow-call-secret-definition": { + "one-of": [ + "null", + "workflow-call-secret-mapping-definition" + ] + }, + "workflow-call-secret-mapping-definition": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the secret parameter." + }, + "required": { + "type": "boolean", + "description": "A boolean specifying whether the secret must be supplied." + } + } + } + }, + "workflow-call-outputs": { + "description": "When using the workflow_call keyword, you can optionally specify outputs that are provided by the called workflow to the caller workflow.", + "mapping": { + "loose-key-type": "workflow-call-output-name", + "loose-value-type": "workflow-call-output-definition" + } + }, + "workflow-call-output-name": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the output. The value of is a map of the input's metadata. The must be a unique identifier within the outputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _." + }, + "workflow-call-output-definition": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the output parameter." + }, + "value": { + "type": "workflow-output-context", + "required": true + } + } + } + }, + "workflow-output-context": { + "description": "The value to assign to the output parameter.", + "context": [ + "github", + "inputs", + "vars", + "jobs" + ], + "string": {} + }, + "workflow-dispatch-string": { + "description": "You can now create workflows that are manually triggered with the new workflow_dispatch event. You will then see a 'Run workflow' button on the Actions tab, enabling you to easily trigger a run.", + "string": { + "constant": "workflow_dispatch" + } + }, + "workflow-dispatch": { + "description": "You can now create workflows that are manually triggered with the new workflow_dispatch event. You will then see a 'Run workflow' button on the Actions tab, enabling you to easily trigger a run.", + "one-of": [ + "null", + "workflow-dispatch-mapping" + ] + }, + "workflow-dispatch-mapping": { + "mapping": { + "properties": { + "inputs": "workflow-dispatch-inputs" + } + } + }, + "workflow-dispatch-inputs": { + "description": "Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.", + "mapping": { + "loose-key-type": "workflow-dispatch-input-name", + "loose-value-type": "workflow-dispatch-input" + } + }, + "workflow-dispatch-input-name": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the input. The value of is a map of the input's metadata. The must be a unique identifier within the inputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _." + }, + "workflow-dispatch-input": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the input parameter." + }, + "type": { + "type": "workflow-dispatch-input-type" + }, + "required": { + "type": "boolean", + "description": "A boolean to indicate whether the workflow requires the input parameter. Set to true when the parameter is required." + }, + "default": "workflow-dispatch-input-default", + "options": { + "type": "sequence-of-non-empty-string", + "description": "The options of the dropdown list, if the type is a choice." + } + } + } + }, + "workflow-dispatch-input-type": { + "description": "A string representing the type of the input. This must be one of: boolean, number, string, choice, or environment.", + "one-of": [ + "input-type-string", + "input-type-boolean", + "input-type-number", + "input-type-environment", + "input-type-choice" + ] + }, + "workflow-dispatch-input-default": { + "description": "The default value is used when an input parameter isn't specified in a workflow file.", + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "permissions": { + "description": "You can modify the default permissions granted to the GITHUB_TOKEN, adding or removing access as required, so that you only allow the minimum required access.", + "one-of": [ + "permissions-mapping", + "permission-level-shorthand-read-all", + "permission-level-shorthand-write-all" + ] + }, + "permissions-mapping": { + "mapping": { + "properties": { + "actions": "permission-level-any", + "checks": "permission-level-any", + "contents": "permission-level-any", + "deployments": "permission-level-any", + "discussions": "permission-level-any", + "id-token": "permission-level-write-or-no-access", + "issues": "permission-level-any", + "packages": "permission-level-any", + "pages": "permission-level-any", + "pull-requests": "permission-level-any", + "repository-projects": "permission-level-any", + "security-events": "permission-level-any", + "statuses": "permission-level-any" + } + } + }, + "permission-level-any": { + "description": "The permission level for the GITHUB_TOKEN.", + "one-of": [ + "permission-level-read", + "permission-level-write", + "permission-level-no-access" + ] + }, + "permission-level-read-or-no-access": { + "one-of": [ + "permission-level-read", + "permission-level-no-access" + ] + }, + "permission-level-write-or-no-access": { + "one-of": [ + "permission-level-write", + "permission-level-no-access" + ] + }, + "permission-level-read": { + "description": "The permission level for the GITHUB_TOKEN. Grants write permission for the specified scope.", + "string": { + "constant": "read" + } + }, + "permission-level-write": { + "description": "The permission level for the GITHUB_TOKEN. Grants write permission for the specified scope.", + "string": { + "constant": "write" + } + }, + "permission-level-no-access": { + "description": "The permission level for the GITHUB_TOKEN. Restricts all access for the specified scope.", + "string": { + "constant": "none" + } + }, + "permission-level-shorthand-read-all": { + "description": "The permission level for the GITHUB_TOKEN. Grants read access for all scopes.", + "string": { + "constant": "read-all" + } + }, + "permission-level-shorthand-write-all": { + "description": "The permission level for the GITHUB_TOKEN. Grants write access for all scopes.", + "string": { + "constant": "write-all" + } + }, + "workflow-defaults": { + "mapping": { + "properties": { + "run": "workflow-defaults-run" + } + } + }, + "workflow-defaults-run": { + "mapping": { + "properties": { + "shell": "shell", + "working-directory": "working-directory" + } + } + }, + "workflow-env": { + "description": "To set custom environment variables, you need to specify the variables in the workflow file. You can define environment variables for a step, job, or entire workflow using the jobs..steps[*].env, jobs..env, and env keywords. For more information, see https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepsenv", + "context": [ + "github", + "inputs", + "vars", + "secrets" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "jobs": { + "description": "A workflow run is made up of one or more jobs. Jobs run in parallel by default. To run jobs sequentially, you can define dependencies on other jobs using the jobs..needs keyword. Each job runs in a fresh instance of the virtual environment specified by runs-on. You can run an unlimited number of jobs as long as you are within the workflow usage limits. For more information, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#usage-limits.", + "mapping": { + "loose-key-type": "job-id", + "loose-value-type": "job" + } + }, + "job-id": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the job. The value of is a map of the input's metadata. The must be a unique identifier within the inputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _." + }, + "job": { + "description": "Each job must have an id to associate with the job. The key job_id is a string and its value is a map of the job's configuration data. You must replace with a string that is unique to the jobs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _.", + "one-of": [ + "job-factory", + "workflow-job" + ] + }, + "job-factory": { + "mapping": { + "properties": { + "needs": "needs", + "if": "job-if", + "strategy": "strategy", + "name": { + "type": "string-strategy-context", + "description": "The name of the job displayed on GitHub." + }, + "runs-on": { + "type": "runs-on", + "required": true + }, + "timeout-minutes": { + "type": "number-strategy-context", + "description": "The maximum number of minutes to let a workflow run before GitHub automatically cancels it. Default: 360" + }, + "cancel-timeout-minutes": "number-strategy-context", + "continue-on-error": { + "type": "boolean-strategy-context", + "description": "Prevents a workflow run from failing when a job fails. Set to true to allow a workflow run to pass when this job fails." + }, + "container": "container", + "services": "services", + "env": "job-env", + "environment": "job-environment", + "permissions": "permissions", + "concurrency": "job-concurrency", + "outputs": "job-outputs", + "defaults": "job-defaults", + "steps": "steps" + } + } + }, + "workflow-job": { + "mapping": { + "properties": { + "name": { + "type": "string-strategy-context", + "description": "The name of the job displayed on GitHub." + }, + "uses": { + "description": "The location and version of a reusable workflow file to run as a job, of the form './{path/to}/{localfile}.yml' or '{owner}/{repo}/{path}/{filename}@{ref}'. {ref} can be a SHA, a release tag, or a branch name. Using the commit SHA is the safest for stability and security.", + "type": "non-empty-string", + "required": true + }, + "with": "workflow-job-with", + "secrets": "workflow-job-secrets", + "needs": "needs", + "if": "job-if", + "permissions": "permissions", + "concurrency": "job-concurrency", + "strategy": "strategy" + } + } + }, + "workflow-job-with": { + "description": "A map of inputs that are passed to the called workflow. Any inputs that you pass must match the input specifications defined in the called workflow. Unlike 'jobs..steps[*].with', the inputs you pass with 'jobs..with' are not be available as environment variables in the called workflow. Instead, you can reference the inputs by using the inputs context.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "scalar-needs-context" + } + }, + "workflow-job-secrets": { + "description": "When a job is used to call a reusable workflow, you can use 'secrets' to provide a map of secrets that are passed to the called workflow. Any secrets that you pass must match the names defined in the called workflow.", + "one-of": [ + "workflow-job-secrets-mapping", + "workflow-job-secrets-inherit" + ] + }, + "workflow-job-secrets-mapping": { + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "scalar-needs-context-with-secrets" + } + }, + "workflow-job-secrets-inherit": { + "string": { + "constant": "inherit" + } + }, + "needs": { + "description": "Identifies any jobs that must complete successfully before this job will run. It can be a string or array of strings. If a job fails, all jobs that need it are skipped unless the jobs use a conditional statement that causes the job to continue.", + "one-of": [ + "sequence-of-non-empty-string", + "non-empty-string" + ] + }, + "job-if": { + "description": "You can use the if conditional to prevent a job from running unless a condition is met. You can use any supported context and expression to create a conditional. Expressions in an if conditional do not require the bracketed expression syntax. For more information, see https://help.github.com/en/articles/contexts-and-expression-syntax-for-github-actions.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "always(0,0)", + "failure(0,MAX)", + "cancelled(0,0)", + "success(0,MAX)" + ], + "string": { + "is-expression": true + } + }, + "job-if-result": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "always(0,0)", + "failure(0,MAX)", + "cancelled(0,0)", + "success(0,MAX)" + ], + "one-of": [ + "null", + "boolean", + "number", + "string", + "sequence", + "mapping" + ] + }, + "strategy": { + "description": "A strategy creates a build matrix for your jobs. You can define different variations of an environment to run each job in.", + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "mapping": { + "properties": { + "fail-fast": { + "type": "boolean", + "description": "When set to true, GitHub cancels all in-progress jobs if any matrix job fails. Default: true" + }, + "max-parallel": { + "type": "number", + "description": "The maximum number of jobs that can run simultaneously when using a matrix job strategy. By default, GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines." + }, + "matrix": "matrix" + } + } + }, + "matrix": { + "description": "A build matrix is a set of different configurations of the virtual environment. For example you might run a job against more than one supported version of a language, operating system, or tool. Each configuration is a copy of the job that runs and reports a status. You can specify a matrix by supplying an array for the configuration options. For example, if the GitHub virtual environment supports Node.js versions 6, 8, and 10 you could specify an array of those versions in the matrix. When you define a matrix of operating systems, you must set the required runs-on keyword to the operating system of the current job, rather than hard-coding the operating system name. To access the operating system name, you can use the matrix.os context parameter to set runs-on. For more information, see https://help.github.com/en/articles/contexts-and-expression-syntax-for-github-actions.", + "mapping": { + "properties": { + "include": { + "type": "matrix-filter", + "description": "Use include to expand existing matrix configurations or to add new configurations. The value of include is a list of objects." + }, + "exclude": { + "type": "matrix-filter", + "description": "To remove specific configurations defined in the matrix, use exclude. An excluded configuration only has to be a partial match for it to be excluded." + } + }, + "loose-key-type": "non-empty-string", + "loose-value-type": "sequence" + } + }, + "matrix-filter": { + "sequence": { + "item-type": "matrix-filter-item" + } + }, + "matrix-filter-item": { + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "any" + } + }, + "runs-on": { + "description": "The type of machine to run the job on. The machine can be either a GitHub-hosted runner, or a self-hosted runner.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string", + "runs-on-mapping" + ] + }, + "runs-on-mapping": { + "mapping": { + "properties": { + "group": { + "description": "The group from which to select a runner.", + "type": "non-empty-string" + }, + "labels": "runs-on-labels" + } + } + }, + "runs-on-labels": { + "description": "The label by which to filter for available runners.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "job-env": { + "description": "A map of environment variables that are available to all steps in the job.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "workflow-concurrency": { + "description": "Concurrency ensures that only a single workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context. You can also specify concurrency at the workflow level.", + "context": [ + "github", + "inputs", + "vars" + ], + "one-of": [ + "string", + "concurrency-mapping" + ] + }, + "job-concurrency": { + "description": "Concurrency ensures that only a single job using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context. You can also specify concurrency at the workflow level.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "non-empty-string", + "concurrency-mapping" + ] + }, + "concurrency-mapping": { + "description": "Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context. You can also specify concurrency at the workflow level.", + "mapping": { + "properties": { + "group": { + "type": "non-empty-string", + "required": true, + "description": "When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. Any previously pending job or workflow in the concurrency group will be canceled." + }, + "cancel-in-progress": { + "type": "boolean", + "description": "To cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true." + } + } + } + }, + "job-environment": { + "description": "The environment that the job references. All environment protection rules must pass before a job referencing the environment is sent to a runner.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "job-environment-mapping" + ] + }, + "job-environment-mapping": { + "mapping": { + "properties": { + "name": { + "type": "job-environment-name", + "required": true + }, + "url": { + "type": "string-runner-context-no-secrets", + "description": "The environment URL, which maps to `environment_url` in the deployments API." + } + } + } + }, + "job-environment-name": { + "description": "The name of the environment.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "string": {} + }, + "job-defaults": { + "description": "A map of default settings that will apply to all steps in the job.", + "mapping": { + "properties": { + "run": "job-defaults-run" + } + } + }, + "job-defaults-run": { + "context": [ + "github", + "inputs", + "vars", + "strategy", + "matrix", + "needs", + "env" + ], + "mapping": { + "properties": { + "shell": "shell", + "working-directory": "working-directory" + } + } + }, + "job-outputs": { + "description": "A map of outputs for a job. Job outputs are available to all downstream jobs that depend on this job.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string-runner-context" + } + }, + "steps": { + "description": "A job contains a sequence of tasks called steps. Steps can run commands, run setup tasks, or run an action in your repository, a public repository, or an action published in a Docker registry. Not all steps run actions, but all actions run as a step. Each step runs in its own process in the virtual environment and has access to the workspace and filesystem. Because steps run in their own process, changes to environment variables are not preserved between steps. GitHub provides built-in steps to set up and complete a job. Must contain either `uses` or `run`.", + "sequence": { + "item-type": "steps-item" + } + }, + "steps-item": { + "one-of": [ + "run-step", + "regular-step" + ] + }, + "run-step": { + "mapping": { + "properties": { + "name": "step-name", + "id": "step-id", + "if": "step-if", + "timeout-minutes": "step-timeout-minutes", + "run": { + "type": "string-steps-context", + "description": "Runs command-line programs using the operating system's shell. If you do not provide a name, the step name will default to the text specified in the run command. Commands run using non-login shells by default. You can choose a different shell and customize the shell used to run commands. Each run keyword represents a new process and shell in the virtual environment. When you provide multi-line commands, each line runs in the same shell.", + "required": true + }, + "continue-on-error": "step-continue-on-error", + "env": "step-env", + "working-directory": "string-steps-context", + "shell": "shell" + } + } + }, + "regular-step": { + "mapping": { + "properties": { + "name": "step-name", + "id": "step-id", + "if": "step-if", + "continue-on-error": "step-continue-on-error", + "timeout-minutes": "step-timeout-minutes", + "uses": { + "description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image (https://hub.docker.com/).", + "type": "non-empty-string", + "required": true + }, + "with": "step-with", + "env": "step-env" + } + } + }, + "step-continue-on-error": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "boolean": {}, + "description": "Prevents a job from failing when a step fails. Set to true to allow a job to pass when this step fails." + }, + "step-id": { + "string": { + "require-non-empty": true + }, + "description": "A unique identifier for the step. You can use the id to reference the step in contexts." + }, + "step-if": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "steps", + "job", + "runner", + "env", + "always(0,0)", + "failure(0,0)", + "cancelled(0,0)", + "success(0,0)", + "hashFiles(1,255)" + ], + "description": "You can use the if conditional to prevent a step from running unless a condition is met. You can use any supported context and expression to create a conditional. Expressions in an if conditional do not require the bracketed expression syntax.", + "string": { + "is-expression": true + } + }, + "step-if-result": { + "context": [ + "github", + "inputs", + "vars", + "strategy", + "matrix", + "steps", + "job", + "runner", + "env", + "always(0,0)", + "failure(0,0)", + "cancelled(0,0)", + "success(0,0)", + "hashFiles(1,255)" + ], + "one-of": [ + "null", + "boolean", + "number", + "string", + "sequence", + "mapping" + ] + }, + "step-env": { + "description": "Sets environment variables for steps to use in the virtual environment. You can also set environment variables for the entire workflow or a job.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "step-name": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "string": {}, + "description": "A name for your step to display on GitHub." + }, + "step-timeout-minutes": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "number": {}, + "description": "The maximum number of minutes to run the step before killing the process." + }, + "step-with": { + "description": "A map of the input parameters defined by the action. Each input parameter is a key/value pair. Input parameters are set as environment variables. The variable is prefixed with INPUT_ and converted to upper case.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "container": { + "description": "A container to run any steps in a job that don't already specify a container. If you have steps that use both script and container actions, the container actions will run as sibling containers on the same network with the same volume mounts. If you do not set a container, all steps will run directly on the host specified by runs-on unless a step refers to an action configured to run in a container.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "container-mapping" + ] + }, + "container-mapping": { + "mapping": { + "properties": { + "image": { + "type": "non-empty-string", + "description": "The Docker image to use as the container to run the job. The value can be the Docker Hub image name or a registry name." + }, + "options": { + "type": "non-empty-string", + "description": "Additional Docker container resource options. For a list of options, see https://docs.docker.com/engine/reference/commandline/create/#options." + }, + "env": "container-env", + "ports": { + "type": "sequence-of-non-empty-string", + "description": "Sets an array of ports to expose on the container." + }, + "volumes": { + "type": "sequence-of-non-empty-string", + "description": "Sets an array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host.\nTo specify a volume, you specify the source and destination path: :\nThe is a volume name or an absolute path on the host machine, and is an absolute path in the container." + }, + "credentials": "container-registry-credentials" + } + } + }, + "services": { + "description": "Additional containers to host services for a job in a workflow. These are useful for creating databases or cache services like redis. The runner on the virtual machine will automatically create a network and manage the life cycle of the service containers. When you use a service container for a job or your step uses container actions, you don't need to set port information to access the service. Docker automatically exposes all ports between containers on the same network. When both the job and the action run in a container, you can directly reference the container by its hostname. The hostname is automatically mapped to the service name. When a step does not use a container action, you must access the service using localhost and bind the ports.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "services-container" + } + }, + "services-container": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "non-empty-string", + "container-mapping" + ] + }, + "container-registry-credentials": { + "description": "If the image's container registry requires authentication to pull the image, you can use credentials to set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.", + "context": [ + "github", + "inputs", + "vars", + "secrets", + "env" + ], + "mapping": { + "properties": { + "username": "non-empty-string", + "password": "non-empty-string" + } + } + }, + "container-env": { + "description": "Sets an array of environment variables in the container.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string-runner-context" + } + }, + "non-empty-string": { + "string": { + "require-non-empty": true + } + }, + "sequence-of-non-empty-string": { + "sequence": { + "item-type": "non-empty-string" + } + }, + "boolean-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "boolean": {} + }, + "number-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "number": {} + }, + "string-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "string": {} + }, + "scalar-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "scalar-needs-context-with-secrets": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "secrets", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "boolean-strategy-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "boolean": {} + }, + "number-strategy-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "number": {} + }, + "string-strategy-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "string": {} + }, + "boolean-steps-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "boolean": {} + }, + "number-steps-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "number": {} + }, + "string-runner-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env" + ], + "string": {} + }, + "string-runner-context-no-secrets": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "steps", + "job", + "runner", + "env" + ], + "string": {} + }, + "string-steps-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "string": {} + }, + "shell": { + "string": { + "require-non-empty": true + }, + "description": "You can override the default shell settings in the runner's operating system using the shell keyword. You can use built-in shell keywords, or you can define a custom set of shell options." + }, + "working-directory": { + "string": { + "require-non-empty": true + }, + "description": "Using the working-directory keyword, you can specify the working directory of where to run the command." + }, + "cron-mapping": { + "mapping": { + "properties": { + "cron": "cron-pattern" + } + } + }, + "cron-pattern": { + "string": { + "require-non-empty": true + } + } + } +} \ No newline at end of file diff --git a/actions-workflow-parser/src/workflows/yaml-object-reader.test.ts b/actions-workflow-parser/src/workflows/yaml-object-reader.test.ts new file mode 100644 index 0000000..34c6fa0 --- /dev/null +++ b/actions-workflow-parser/src/workflows/yaml-object-reader.test.ts @@ -0,0 +1,102 @@ +import { + TemplateContext, + TemplateValidationErrors, +} from "../templates/template-context" +import { TemplateToken } from "../templates/tokens" +import { TokenType } from "../templates/tokens/types" +import { nullTrace } from "../test-utils/null-trace" +import { parseWorkflow } from "./workflow-parser" +import { getWorkflowSchema } from "./workflow-schema" +import { YamlObjectReader } from "./yaml-object-reader" + +describe("getLiteralToken", () => { + it("non-zero number", () => { + const result = parseAsWorkflow("1") + + expect(result).not.toBeUndefined() + expect(result?.templateTokenType).toEqual(TokenType.Number) + expect(result?.toString()).toEqual("1") + }) + + it("zero", () => { + const result = parseAsWorkflow("0") + + expect(result).not.toBeUndefined() + expect(result?.templateTokenType).toEqual(TokenType.Number) + expect(result?.toString()).toEqual("0") + }) + + it("true", () => { + const result = parseAsWorkflow("true") + + expect(result).not.toBeUndefined() + expect(result?.templateTokenType).toEqual(TokenType.Boolean) + expect(result?.toString()).toEqual("true") + }) + + it("false", () => { + const result = parseAsWorkflow("false") + + expect(result).not.toBeUndefined() + expect(result?.templateTokenType).toEqual(TokenType.Boolean) + expect(result?.toString()).toEqual("false") + }) + + it("string", () => { + const result = parseAsWorkflow("test") + + expect(result).not.toBeUndefined() + expect(result?.templateTokenType).toEqual(TokenType.String) + expect(result?.toString()).toEqual("test") + }) + + it("null", () => { + const result = parseAsWorkflow("null") + + expect(result).not.toBeUndefined() + expect(result?.templateTokenType).toEqual(TokenType.Null) + expect(result?.toString()).toEqual("") + }) +}) + +it("YAML errors include range information", () => { + const content = ` + on: push + jobs: + build: + runs-on: ubuntu-latest + steps: + - name: 'Hello \${{ fromJSON('test') == inputs.name }}' + run: echo Hello, world!` + + const context = new TemplateContext( + new TemplateValidationErrors(), + getWorkflowSchema(), + nullTrace + ) + const fileId = context.getFileId("test.yaml") + new YamlObjectReader(context, fileId, content) + + expect(context.errors.count).toBe(1) + + const error = context.errors.getErrors()[0] + expect(error.range).toEqual({ + start: [7, 38], + end: [7, 63], + }) +}) + +function parseAsWorkflow(content: string): TemplateToken | undefined { + const result = parseWorkflow( + "test.yaml", + [ + { + name: "test.yaml", + content: content, + }, + ], + nullTrace + ) + + return result.value +} diff --git a/actions-workflow-parser/src/workflows/yaml-object-reader.ts b/actions-workflow-parser/src/workflows/yaml-object-reader.ts new file mode 100644 index 0000000..7122aab --- /dev/null +++ b/actions-workflow-parser/src/workflows/yaml-object-reader.ts @@ -0,0 +1,263 @@ +import { + isCollection, + isDocument, + isMap, + isPair, + isScalar, + isSeq, + LineCounter, + parseDocument, + Scalar, +} from "yaml" +import { LinePos } from "yaml/dist/errors" +import { NodeBase } from "yaml/dist/nodes/Node" +import { ObjectReader } from "../templates/object-reader" +import { EventType, ParseEvent } from "../templates/parse-event" +import { TemplateContext } from "../templates/template-context" +import { + BooleanToken, + LiteralToken, + MappingToken, + NullToken, + NumberToken, + SequenceToken, + StringToken, +} from "../templates/tokens/index" +import { Position, TokenRange } from "../templates/tokens/token-range" + +export class YamlObjectReader implements ObjectReader { + private readonly _generator: Generator + private _current!: IteratorResult + private fileId?: number + private lineCounter = new LineCounter() + + constructor( + context: TemplateContext, + fileId: number | undefined, + content: string + ) { + const doc = parseDocument(content, { + lineCounter: this.lineCounter, + keepSourceTokens: true, + uniqueKeys: false, // Uniqueness is validated by the template reader + }) + for (const err of doc.errors) { + context.error(fileId, err.message, rangeFromLinePos(err.linePos)) + } + this._generator = this.getNodes(doc) + this.fileId = fileId + } + + private *getNodes(node: unknown): Generator { + let range = this.getRange(node as NodeBase | undefined) + + if (isDocument(node)) { + yield new ParseEvent(EventType.DocumentStart) + for (const item of this.getNodes(node.contents)) { + yield item + } + yield new ParseEvent(EventType.DocumentEnd) + } + + if (isCollection(node)) { + if (isSeq(node)) { + yield new ParseEvent( + EventType.SequenceStart, + new SequenceToken(this.fileId, range, undefined) + ) + } else if (isMap(node)) { + yield new ParseEvent( + EventType.MappingStart, + new MappingToken(this.fileId, range, undefined) + ) + } + + for (const item of node.items) { + for (const child of this.getNodes(item)) { + yield child + } + } + if (isSeq(node)) { + yield new ParseEvent(EventType.SequenceEnd) + } else if (isMap(node)) { + yield new ParseEvent(EventType.MappingEnd) + } + } + + if (isScalar(node)) { + yield new ParseEvent( + EventType.Literal, + YamlObjectReader.getLiteralToken(this.fileId, range, node as Scalar) + ) + } + + if (isPair(node)) { + const scalarKey = node.key as Scalar + range = this.getRange(scalarKey) + const key = scalarKey.value as string + yield new ParseEvent( + EventType.Literal, + new StringToken(this.fileId, range, key, undefined) + ) + for (const child of this.getNodes(node.value)) { + yield child + } + } + } + + private getRange(node: NodeBase | undefined): TokenRange | undefined { + const range = node?.range ?? [] + const startPos = range[0] + const endPos = range[1] + + if (startPos !== undefined && endPos !== undefined) { + const slp = this.lineCounter.linePos(startPos) + const elp = this.lineCounter.linePos(endPos) + + return { + start: [slp.line, slp.col], + end: [elp.line, elp.col], + } + } + + return undefined + } + + private static getLiteralToken( + fileId: number | undefined, + range: TokenRange | undefined, + token: Scalar + ) { + const value = token.value + + if (value === null || value === undefined) { + return new NullToken(fileId, range, undefined) + } + + switch (typeof value) { + case "number": + return new NumberToken(fileId, range, value, undefined) + case "boolean": + return new BooleanToken(fileId, range, value, undefined) + case "string": { + // If the string is a YAML block string, include the original source + let source: string | undefined + if ( + (token.type === "BLOCK_LITERAL" || // | multi-line strings + token.type === "BLOCK_FOLDED") && // > multi-line strings + token.srcToken && + token.srcToken.type === "block-scalar" + ) { + source = token.srcToken.source + } + + return new StringToken(fileId, range, value, undefined, source) + } + default: + throw new Error( + `Unexpected value type '${typeof value}' when reading object` + ) + } + } + + public allowLiteral(): LiteralToken | undefined { + if (!this._current.done) { + const parseEvent = this._current.value + if (parseEvent.type === EventType.Literal) { + this._current = this._generator.next() + return parseEvent.token as LiteralToken + } + } + + return undefined + } + + public allowSequenceStart(): SequenceToken | undefined { + if (!this._current.done) { + const parseEvent = this._current.value + if (parseEvent.type === EventType.SequenceStart) { + this._current = this._generator.next() + return parseEvent.token as SequenceToken + } + } + + return undefined + } + + public allowSequenceEnd(): boolean { + if (!this._current.done) { + const parseEvent = this._current.value + if (parseEvent.type === EventType.SequenceEnd) { + this._current = this._generator.next() + return true + } + } + + return false + } + + public allowMappingStart(): MappingToken | undefined { + if (!this._current.done) { + const parseEvent = this._current.value + if (parseEvent.type === EventType.MappingStart) { + this._current = this._generator.next() + return parseEvent.token as MappingToken + } + } + + return undefined + } + + public allowMappingEnd(): boolean { + if (!this._current.done) { + const parseEvent = this._current.value + if (parseEvent.type === EventType.MappingEnd) { + this._current = this._generator.next() + return true + } + } + + return false + } + + public validateEnd(): void { + if (!this._current.done) { + const parseEvent = this._current.value as ParseEvent + if (parseEvent.type === EventType.DocumentEnd) { + this._current = this._generator.next() + return + } + } + + throw new Error("Expected end of reader") + } + + public validateStart(): void { + if (!this._current) { + this._current = this._generator.next() + } + + if (!this._current.done) { + const parseEvent = this._current.value as ParseEvent + if (parseEvent.type === EventType.DocumentStart) { + this._current = this._generator.next() + return + } + } + + throw new Error("Expected start of reader") + } +} + +function rangeFromLinePos( + linePos: [LinePos] | [LinePos, LinePos] | undefined +): TokenRange | undefined { + if (linePos === undefined) { + return + } + // TokenRange and linePos are both 1-based + const start: Position = [linePos[0].line, linePos[0].col] + const end: Position = + linePos.length == 2 ? [linePos[1].line, linePos[1].col] : start + return { start, end } +} diff --git a/actions-workflow-parser/src/xlang.test.ts b/actions-workflow-parser/src/xlang.test.ts new file mode 100644 index 0000000..c6aec31 --- /dev/null +++ b/actions-workflow-parser/src/xlang.test.ts @@ -0,0 +1,110 @@ +import * as fs from "fs" +import * as path from "path" +import * as YAML from "yaml" +import { convertWorkflowTemplate } from "./model/convert" +import { TraceWriter } from "./templates/trace-writer" +import { parseWorkflow } from "./workflows/workflow-parser" + +interface TestOptions { + "include-source"?: boolean + skip?: string[] +} + +const nullTrace: TraceWriter = { + info: (x) => {}, + verbose: (x) => {}, + error: (x) => {}, +} + +const testFiles = "./testData/reader" + +describe("x-lang tests", () => { + const files = fs.readdirSync(testFiles) + + for (const file of files) { + const fileName = path.join(testFiles, file) + + const fileStat = fs.statSync(fileName) + if (fileStat.isDirectory()) { + throw new Error("sub-directories are not supported") + } + + if ( + path.extname(fileName) !== ".yml" && + path.extname(fileName) !== ".yaml" + ) { + throw new Error("only y(a)ml files are supported " + file) + } + + const inputFile = fs.readFileSync(fileName, "utf8") + + const testDocs: string[] = inputFile.split(/\r?\n---\r?\n/) + expect(testDocs.length).toBeGreaterThanOrEqual(3) + + const testOptions: TestOptions = YAML.parse(testDocs[0]) + const unsupportedTest = contains(testOptions.skip, "TypeScript") + + const test = () => { + let testFileName = + ".github/workflows" + fileName.substring(fileName.lastIndexOf("/")) + let testInput = testDocs[1] + let expectedTemplate = testDocs[2].trim() + // TODO: when reusable workflows are implemented, implement correctly + if (fileName.indexOf("reusable") !== -1) { + testFileName = testDocs[1] + testInput = testDocs[2] + expectedTemplate = testDocs[3].trim() + } + + const parseResult = parseWorkflow( + testFileName, + [ + { + name: testFileName, + content: testInput, + }, + ], + nullTrace + ) + + expect(parseResult.value).not.toBeUndefined() + + const workflowTemplate = convertWorkflowTemplate( + parseResult.context, + parseResult.value! + ) + + // Unless this tests is only used by TypeScript, remove the events for now. + // TODO: Remove this once we parse events everywhere + const includeEvents = + testOptions.skip !== undefined && + contains(testOptions.skip, "Go") && + contains(testOptions.skip, "C#") + if (!includeEvents) { + delete (workflowTemplate as any).events + } + + // Other parsers don't have a partial template when there are errors + if (workflowTemplate.errors) { + delete (workflowTemplate as any).jobs + } + + const result = JSON.stringify(workflowTemplate, null, " ") + expect(result).toBe(expectedTemplate) + } + + if (unsupportedTest) { + it.failing(fileName, test) + } else { + it(fileName, test) + } + } +}) + +// Case-insensitive contains +function contains(arr: string[] | undefined, term: string): boolean { + if (!arr) { + return false + } + return arr.map((x) => x.toLowerCase()).indexOf(term.toLowerCase()) !== -1 +} diff --git a/actions-workflow-parser/testdata/reader/bad-character.yml b/actions-workflow-parser/testdata/reader/bad-character.yml new file mode 100644 index 0000000..24b7c6c --- /dev/null +++ b/actions-workflow-parser/testdata/reader/bad-character.yml @@ -0,0 +1,79 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +name: Update awesome list + +on: + workflow_dispatch: + schedule: + - cron: '0 */12 * * *' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Awesome generator + uses: simonecorsi/mawesome@v2 + with: + api-token: ${{ secrets.API_TOKEN }} # <--- there is a unicode whitespace character here, VSCode will highlight it, the web UI does not + compact-by-topic: 'true' + github-name: ${{ github.repository_owner }} +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__simonecorsi_mawesome", + "name": "Awesome generator", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "simonecorsi/mawesome@v2", + "with": { + "type": 2, + "map": [ + { + "Key": "api-token", + "Value": { + "type": 3, + "expr": "secrets.API_TOKEN" + } + }, + { + "Key": "compact-by-topic", + "Value": "true" + }, + { + "Key": "github-name", + "Value": { + "type": 3, + "expr": "github.repository_owner" + } + } + ] + } + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/basic.yml b/actions-workflow-parser/testdata/reader/basic.yml new file mode 100644 index 0000000..c969b58 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/basic.yml @@ -0,0 +1,44 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/concurrency.yml b/actions-workflow-parser/testdata/reader/concurrency.yml new file mode 100644 index 0000000..ad07011 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/concurrency.yml @@ -0,0 +1,148 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true + concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + build2: + runs-on: linux + concurrency: ci-${{ github.ref }} + build3: + runs-on: linux + concurrency: staging + build4: + runs-on: macos-latest + concurrency: + group: ref + cancel-in-progress: ${{ github.ref }} + + +--- +{ + "concurrency": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": { + "type": 3, + "expr": "github.ref" + } + }, + { + "Key": "cancel-in-progress", + "Value": true + } + ] + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "concurrency": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": { + "type": 3, + "expr": "github.ref" + } + }, + { + "Key": "cancel-in-progress", + "Value": true + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "concurrency": { + "type": 3, + "expr": "format('ci-{0}', github.ref)" + }, + "runs-on": "linux" + }, + { + "type": "job", + "id": "build3", + "name": "build3", + "if": { + "type": 3, + "expr": "success()" + }, + "concurrency": "staging", + "runs-on": "linux" + }, + { + "type": "job", + "id": "build4", + "name": "build4", + "if": { + "type": 3, + "expr": "success()" + }, + "concurrency": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": "ref" + }, + { + "Key": "cancel-in-progress", + "Value": { + "type": 3, + "expr": "github.ref" + } + } + ] + }, + "runs-on": "macos-latest" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/error.yml b/actions-workflow-parser/testdata/reader/error.yml new file mode 100644 index 0000000..29b9332 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/error.yml @@ -0,0 +1,20 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + # Purposeful typo in run + - runn: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/error.yml (Line: 7, Col: 9): Unexpected value 'runn'" + }, + { + "Message": ".github/workflows/error.yml (Line: 7, Col: 9): There's not enough info to determine what you meant. Add one of these properties: run, shell, uses, with, working-directory" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-anchor-tag.yml b/actions-workflow-parser/testdata/reader/errors-anchor-tag.yml new file mode 100644 index 0000000..9382677 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-anchor-tag.yml @@ -0,0 +1,18 @@ +include-source: false # Drop file/line/col from output +skip: +- TypeScript +--- +on: push +jobs: + bad-anchor-tag: + runs-on: ubuntu-latest + steps: + - run: &echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-anchor-tag.yml: Anchors are not currently supported. Remove the anchor 'echo'" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-empty-expression.yml b/actions-workflow-parser/testdata/reader/errors-empty-expression.yml new file mode 100644 index 0000000..11ad48b --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-empty-expression.yml @@ -0,0 +1,16 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ${{ }} +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-empty-expression.yml (Line: 6, Col: 14): An expression was expected" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-expression-not-allowed.yml b/actions-workflow-parser/testdata/reader/errors-expression-not-allowed.yml new file mode 100644 index 0000000..6929077 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-expression-not-allowed.yml @@ -0,0 +1,21 @@ +include-source: false # Drop file/line/col from output +--- +name: Mixed String Expr ${{ format('hello {0}', 'world') }} +on: + ${{ format('expression-mapping-key {0}', 'as-key') }}: +jobs: + ${{ format('hello {0}', 'world') }} +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-expression-not-allowed.yml (Line: 1, Col: 7): A template expression is not allowed in this context" + }, + { + "Message": ".github/workflows/errors-expression-not-allowed.yml (Line: 3, Col: 3): A template expression is not allowed in this context" + }, + { + "Message": ".github/workflows/errors-expression-not-allowed.yml (Line: 5, Col: 3): A template expression is not allowed in this context" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-expression-not-closed.yml b/actions-workflow-parser/testdata/reader/errors-expression-not-closed.yml new file mode 100644 index 0000000..9c30fee --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-expression-not-closed.yml @@ -0,0 +1,16 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ${{ github.event_name +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-expression-not-closed.yml (Line: 6, Col: 14): The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-expressions.yml b/actions-workflow-parser/testdata/reader/errors-expressions.yml new file mode 100644 index 0000000..4836ff0 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-expressions.yml @@ -0,0 +1,79 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + concurrency: ${{ secrets.foo #no closing brace + steps: + - run: echo hi + build2: + runs-on: ubuntu-latest + concurrency: ${{ secrets.foo }} + steps: + - run: echo hi + build3: + runs-on: ubuntu-latest + concurrency: ${{ }} #empty expression + steps: + - run: echo hi + build4: + if: ${{ env == 5 }} #env not defined + runs-on: linux + steps: + - run: echo hi + build5: + if: ${{ "0xFZ" }} + runs-on: linux + steps: + - run: echo hi + build6: + if: ${{ withJson() }} #not real function + runs-on: linux + steps: + - run: echo hi + build7: + if: ${{ foo" }} #missing starting quotation + runs-on: linux + steps: + - run: echo hi + build8: + runs-on: linux + cancel-timeout-minutes: ${{ "0x" }} #Not a number + steps: + - run: echo hi + +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-expressions.yml (Line: 5, Col: 18): The expression is not closed. An unescaped ${{ sequence was found, but the closing }} sequence was not found." + }, + { + "Message": ".github/workflows/errors-expressions.yml (Line: 10, Col: 18): Unrecognized named-value: 'secrets'. Located at position 1 within expression: secrets.foo" + }, + { + "Message": ".github/workflows/errors-expressions.yml (Line: 15, Col: 18): An expression was expected" + }, + { + "Message": ".github/workflows/errors-expressions.yml (Line: 19, Col: 9): Unrecognized named-value: 'env'. Located at position 1 within expression: env == 5" + }, + { + "Message": ".github/workflows/errors-expressions.yml (Line: 24, Col: 9): Unexpected symbol: '\"0xFZ\"'. Located at position 1 within expression: \"0xFZ\"" + }, + { + "Message": ".github/workflows/errors-expressions.yml (Line: 29, Col: 9): Unrecognized function: 'withJson'. Located at position 1 within expression: withJson()" + }, + { + "Message": ".github/workflows/errors-expressions.yml (Line: 34, Col: 9): Unexpected symbol: 'foo\"'. Located at position 1 within expression: foo\"" + }, + { + "Message": ".github/workflows/errors-expressions.yml (Line: 40, Col: 29): Unexpected symbol: '\"0x\"'. Located at position 1 within expression: \"0x\"" + }, + { + "Message": ".github/workflows/errors-expressions.yml (Line: 40, Col: 29): Unexpected value '${{ \"0x\" }}'" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-invalid-permissions.yml b/actions-workflow-parser/testdata/reader/errors-invalid-permissions.yml new file mode 100644 index 0000000..c3ff1e2 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-invalid-permissions.yml @@ -0,0 +1,20 @@ +include-source: false # Drop file/line/col from output +--- +on: push +permissions: + contents: invalid +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-invalid-permissions.yml (Line: 3, Col: 13): Unexpected value 'invalid'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-job-concurrency.yml b/actions-workflow-parser/testdata/reader/errors-job-concurrency.yml new file mode 100644 index 0000000..4fc9392 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-concurrency.yml @@ -0,0 +1,19 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + concurrency: ${{ secrets.foo }} + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-concurrency.yml (Line: 5, Col: 18): Unrecognized named-value: 'secrets'. Located at position 1 within expression: secrets.foo" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-job-id-empty.yml b/actions-workflow-parser/testdata/reader/errors-job-id-empty.yml new file mode 100644 index 0000000..f13a40b --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-id-empty.yml @@ -0,0 +1,18 @@ +include-source: false # Drop file/line/col from output +--- +name: CI +on: + push: +jobs: + "": + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-id-empty.yml (Line: 5, Col: 3): Unexpected value ''" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-job-id-format.yml b/actions-workflow-parser/testdata/reader/errors-job-id-format.yml new file mode 100644 index 0000000..78be26a --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-id-format.yml @@ -0,0 +1,67 @@ +include-source: false # Drop file/line/col from output +--- +name: CI +on: + push: +jobs: + my.job: + runs-on: ubuntu-latest + steps: + - run: echo hi + my%job: + runs-on: ubuntu-latest + steps: + - run: echo hi + my^job: + runs-on: ubuntu-latest + steps: + - run: echo hi + .myjob: + runs-on: ubuntu-latest + steps: + - run: echo hi + 0myjob: + runs-on: ubuntu-latest + steps: + - run: echo hi + " myjob": + runs-on: ubuntu-latest + steps: + - run: echo hi + " ": + runs-on: ubuntu-latest + steps: + - run: echo hi + -myjob: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-id-format.yml (Line: 5, Col: 3): The identifier 'my.job' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters." + }, + { + "Message": ".github/workflows/errors-job-id-format.yml (Line: 9, Col: 3): The identifier 'my%job' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters." + }, + { + "Message": ".github/workflows/errors-job-id-format.yml (Line: 13, Col: 3): The identifier 'my^job' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters." + }, + { + "Message": ".github/workflows/errors-job-id-format.yml (Line: 17, Col: 3): The identifier '.myjob' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters." + }, + { + "Message": ".github/workflows/errors-job-id-format.yml (Line: 21, Col: 3): The identifier '0myjob' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters." + }, + { + "Message": ".github/workflows/errors-job-id-format.yml (Line: 25, Col: 3): The identifier ' myjob' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters." + }, + { + "Message": ".github/workflows/errors-job-id-format.yml (Line: 29, Col: 3): The identifier ' ' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters." + }, + { + "Message": ".github/workflows/errors-job-id-format.yml (Line: 33, Col: 3): The identifier '-myjob' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters." + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-job-id-leading-underscores.yml b/actions-workflow-parser/testdata/reader/errors-job-id-leading-underscores.yml new file mode 100644 index 0000000..43f0334 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-id-leading-underscores.yml @@ -0,0 +1,40 @@ +include-source: false # Drop file/line/col from output +--- +name: CI +on: + push: +jobs: + # Valid + _: + runs-on: ubuntu-latest + steps: + - run: echo hi + _a: + runs-on: ubuntu-latest + steps: + - run: echo hi + a__: + runs-on: ubuntu-latest + steps: + - run: echo hi + + # Invalid + __: + runs-on: ubuntu-latest + steps: + - run: echo hi + __a: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-id-leading-underscores.yml (Line: 20, Col: 3): The identifier '__' is invalid. IDs starting with '__' are reserved." + }, + { + "Message": ".github/workflows/errors-job-id-leading-underscores.yml (Line: 24, Col: 3): The identifier '__a' is invalid. IDs starting with '__' are reserved." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-job-id-unique.yml b/actions-workflow-parser/testdata/reader/errors-job-id-unique.yml new file mode 100644 index 0000000..1371272 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-id-unique.yml @@ -0,0 +1,22 @@ +include-source: false # Drop file/line/col from output +--- +name: CI +on: + push: +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi + MY-JOB: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-id-unique.yml (Line: 9, Col: 3): 'MY-JOB' is already defined" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-job-needs-cycle.yml b/actions-workflow-parser/testdata/reader/errors-job-needs-cycle.yml new file mode 100644 index 0000000..fd1c39f --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-needs-cycle.yml @@ -0,0 +1,34 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + + one: + runs-on: ubuntu-latest + steps: + - run: echo hello + + two: + needs: + - one + - three + runs-on: ubuntu-latest + steps: + - run: echo hello + + three: + needs: two + runs-on: ubuntu-latest + steps: + - run: echo hello +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-needs-cycle.yml (Line: 12, Col: 9): Job 'two' depends on job 'three' which creates a cycle in the dependency graph." + }, + { + "Message": ".github/workflows/errors-job-needs-cycle.yml (Line: 18, Col: 12): Job 'three' depends on job 'two' which creates a cycle in the dependency graph." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-job-needs-no-start-node.yml b/actions-workflow-parser/testdata/reader/errors-job-needs-no-start-node.yml new file mode 100644 index 0000000..45c5195 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-needs-no-start-node.yml @@ -0,0 +1,24 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + + one: + needs: two + runs-on: ubuntu-latest + steps: + - run: echo hello + + two: + needs: one + runs-on: ubuntu-latest + steps: + - run: echo hello +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-needs-no-start-node.yml (Line: 4, Col: 3): The workflow must contain at least one job with no dependencies." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-job-needs-unknown-job.yml b/actions-workflow-parser/testdata/reader/errors-job-needs-unknown-job.yml new file mode 100644 index 0000000..d6876c9 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-needs-unknown-job.yml @@ -0,0 +1,37 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + + one: + needs: two + runs-on: ubuntu-latest + steps: + - run: echo hello + + three: + needs: + - four + - five + runs-on: ubuntu-latest + steps: + - run: echo hello + + six: + runs-on: ubuntu-latest + steps: + - run: echo hello +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-needs-unknown-job.yml (Line: 5, Col: 12): Job 'one' depends on unknown job 'two'." + }, + { + "Message": ".github/workflows/errors-job-needs-unknown-job.yml (Line: 12, Col: 9): Job 'three' depends on unknown job 'four'." + }, + { + "Message": ".github/workflows/errors-job-needs-unknown-job.yml (Line: 13, Col: 9): Job 'three' depends on unknown job 'five'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-job-runs-on-and-uses.yml b/actions-workflow-parser/testdata/reader/errors-job-runs-on-and-uses.yml new file mode 100644 index 0000000..ecbace9 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-runs-on-and-uses.yml @@ -0,0 +1,15 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + uses: ./.github/workflows/foo.yml +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-runs-on-and-uses.yml (Line: 5, Col: 5): Unexpected value 'uses'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-job-runs-on-group-invalid-prefix.yml b/actions-workflow-parser/testdata/reader/errors-job-runs-on-group-invalid-prefix.yml new file mode 100644 index 0000000..2b35981 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-runs-on-group-invalid-prefix.yml @@ -0,0 +1,35 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: + group: ent/org/my-group + steps: + - run: echo hi + build2: + runs-on: + group: asdf/my-group + steps: + - run: echo hi + build3: + runs-on: + group: ent/ + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-runs-on-group-invalid-prefix.yml (Line: 5, Col: 14): Invalid runs-on group name 'ent/org/my-group'. Please use 'organization/' or 'enterprise/' prefix to target a single runner group." + }, + { + "Message": ".github/workflows/errors-job-runs-on-group-invalid-prefix.yml (Line: 10, Col: 14): Invalid runs-on group name 'asdf/my-group'. Please use 'organization/' or 'enterprise/' prefix to target a single runner group." + }, + { + "Message": ".github/workflows/errors-job-runs-on-group-invalid-prefix.yml (Line: 15, Col: 14): Invalid runs-on group name 'ent/'." + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-job-runs-on-missing.yml b/actions-workflow-parser/testdata/reader/errors-job-runs-on-missing.yml new file mode 100644 index 0000000..f646a4e --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-job-runs-on-missing.yml @@ -0,0 +1,15 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: # missing runs-on + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-job-runs-on-missing.yml (Line: 4, Col: 5): Required property is missing: runs-on" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-jobs-at-least-one.yml b/actions-workflow-parser/testdata/reader/errors-jobs-at-least-one.yml new file mode 100644 index 0000000..2bb7aba --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-jobs-at-least-one.yml @@ -0,0 +1,12 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: {} +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-jobs-at-least-one.yml (Line: 2, Col: 7): The workflow must contain at least one job with no dependencies." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-jobs-missing.yml b/actions-workflow-parser/testdata/reader/errors-jobs-missing.yml new file mode 100644 index 0000000..c61f4d8 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-jobs-missing.yml @@ -0,0 +1,11 @@ +include-source: false # Drop file/line/col from output +--- +on: push +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-jobs-missing.yml (Line: 1, Col: 1): Required property is missing: jobs" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-matrix-max-depth.yml b/actions-workflow-parser/testdata/reader/errors-matrix-max-depth.yml new file mode 100644 index 0000000..6822766 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-matrix-max-depth.yml @@ -0,0 +1,56 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + test_matrix: + strategy: + matrix: + complex: + - key-1: + nested-1: + nested-nested-1: + - key-2: + nested-2: + nested-nested-2: + - key-3: + nested-3: + nested-nested-3: + - key-4: + nested-4: + nested-nested-4: + - key-5: + nested-5: + nested-nested-5: + - key-6: + nested-6: + nested-nested-6: + - key-7: + nested-7: + nested-nested-7: + - key-8: + nested-8: + nested-nested-8: + - key-9: + nested-9: + nested-nested-9: + - key-10: + nested-10: + nested-nested-10: + - key-11: + nested-11: + nested-nested-11: + - key-12: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo Building... +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-matrix-max-depth.yml: Maximum object depth exceeded" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-max-depth.yml b/actions-workflow-parser/testdata/reader/errors-max-depth.yml new file mode 100644 index 0000000..539fbee --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-max-depth.yml @@ -0,0 +1,23 @@ +include-source: false # Drop file/line/col from output +max-depth: 5 +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v2 + with: + node-version: 14 # Depth = 5, equals max-depth, raises error + - run: echo Building... +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-max-depth.yml: Maximum object depth exceeded" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-max-file-size.yml b/actions-workflow-parser/testdata/reader/errors-max-file-size.yml new file mode 100644 index 0000000..ec56b7b --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-max-file-size.yml @@ -0,0 +1,20 @@ +include-source: false # Drop file/line/col from output +max-file-size: 124 +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo Building... +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-max-file-size.yml: The maximum file size of 124 characters has been exceeded" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-max-job-limit-with-reusable-workflow.yml b/actions-workflow-parser/testdata/reader/errors-max-job-limit-with-reusable-workflow.yml new file mode 100644 index 0000000..2e7aac0 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-max-job-limit-with-reusable-workflow.yml @@ -0,0 +1,35 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +max-job-limit: 8 +--- +on: push +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 + deploy1: + uses: contoso/templates/.github/workflows/deploy.yml@v1 + deploy2: + uses: contoso/templates/.github/workflows/deploy.yml@v1 +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: workflow_call +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo hi + deploy2: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": "The workflow may not contain more than 8 jobs" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-max-result-size.yml b/actions-workflow-parser/testdata/reader/errors-max-result-size.yml new file mode 100644 index 0000000..67a93c7 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-max-result-size.yml @@ -0,0 +1,21 @@ +include-source: false # Drop file/line/col from output +max-result-size: 768 +skip: + - TypeScript +--- +on: push +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo Deploying 1... + - run: echo Deploying 2... + - run: echo Deploying 3... +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-max-result-size.yml: Maximum object size exceeded" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-on-workflow_call-input-type-missing.yml b/actions-workflow-parser/testdata/reader/errors-on-workflow_call-input-type-missing.yml new file mode 100644 index 0000000..732f44f --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-on-workflow_call-input-type-missing.yml @@ -0,0 +1,27 @@ +include-source: false # Drop file/line/col from output +--- +on: + push: + branches: + - main + workflow_call: + inputs: + app_name: + required: true + type: Datetime + secrets: + shh: + required: true +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-on-workflow_call-input-type-missing.yml (Line: 9, Col: 15): Unexpected value 'Datetime'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-on-workflow_call-input-unexpected-property.yml b/actions-workflow-parser/testdata/reader/errors-on-workflow_call-input-unexpected-property.yml new file mode 100644 index 0000000..df5edd2 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-on-workflow_call-input-unexpected-property.yml @@ -0,0 +1,28 @@ +include-source: false # Drop file/line/col from output +--- +on: + push: + branches: + - main + workflow_call: + inputs: + app_name: + required: true + type: string + deprecationMessage: blah blah + secrets: + shh: + required: true +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-on-workflow_call-input-unexpected-property.yml (Line: 10, Col: 9): Unexpected value 'deprecationMessage'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-on-workflow_call-output.yml b/actions-workflow-parser/testdata/reader/errors-on-workflow_call-output.yml new file mode 100644 index 0000000..8adeffd --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-on-workflow_call-output.yml @@ -0,0 +1,23 @@ +include-source: false # Drop file/line/col from output +--- +on: + push: + branches: + - main + workflow_call: + outputs: + output1: + description: foo +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-on-workflow_call-output.yml (Line: 8, Col: 9): Required property is missing: value" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-parse-boolean.yml b/actions-workflow-parser/testdata/reader/errors-parse-boolean.yml new file mode 100644 index 0000000..ee2f090 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-parse-boolean.yml @@ -0,0 +1,18 @@ +include-source: false # Drop file/line/col from output +skip: +- TypeScript +--- +on: push +jobs: + parse-bool-error: + runs-on: !!bool test + steps: + - uses: actions/checkout@v2 +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-parse-boolean.yml: The value 'test' on line 4 and column 14 is invalid for the type 'tag:yaml.org,2002:bool'" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-parse-float.yml b/actions-workflow-parser/testdata/reader/errors-parse-float.yml new file mode 100644 index 0000000..659bfda --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-parse-float.yml @@ -0,0 +1,18 @@ +include-source: false # Drop file/line/col from output +skip: +- TypeScript +--- +on: push +jobs: + parse-float-error: + runs-on: !!float test + steps: + - uses: actions/checkout@v2 +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-parse-float.yml: The value 'test' on line 4 and column 14 is invalid for the type 'tag:yaml.org,2002:float'" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-parse-integer.yml b/actions-workflow-parser/testdata/reader/errors-parse-integer.yml new file mode 100644 index 0000000..b455ac9 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-parse-integer.yml @@ -0,0 +1,18 @@ +include-source: false # Drop file/line/col from output +skip: +- TypeScript +--- +on: push +jobs: + parse-int-error: + runs-on: !!int test + steps: + - uses: actions/checkout@v2 +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-parse-integer.yml: The value 'test' on line 4 and column 14 is invalid for the type 'tag:yaml.org,2002:int'" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-required-property-missing.yml b/actions-workflow-parser/testdata/reader/errors-required-property-missing.yml new file mode 100644 index 0000000..b6426dd --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-required-property-missing.yml @@ -0,0 +1,36 @@ +include-source: false # Drop file/line/col from output +--- +on: + workflow_call: # missing 'type' + inputs: + username: + description: 'A username passed from the caller workflow' + default: 'john-doe' +jobs: + build: + runs-on: self-hosted + concurrency: # missing 'group' + cancel-in-progress: true + steps: + - run: echo Hi + build2: + runs-on: self-hosted + environment: # missing 'name' + url: https://github.com + steps: + - run: echo Hi + +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-required-property-missing.yml (Line: 5, Col: 9): Required property is missing: type" + }, + { + "Message": ".github/workflows/errors-required-property-missing.yml (Line: 11, Col: 7): Required property is missing: group" + }, + { + "Message": ".github/workflows/errors-required-property-missing.yml (Line: 17, Col: 7): Required property is missing: name" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-inputs-required.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-inputs-required.yml new file mode 100644 index 0000000..df6d826 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-inputs-required.yml @@ -0,0 +1,31 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: + workflow_call: + inputs: + foo: + required: true + type: string +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-job-inputs-required.yml (Line: 4, Col: 11): Input foo is required, but not provided while calling." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-inputs-type-mismatch.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-inputs-type-mismatch.yml new file mode 100644 index 0000000..1df5ce1 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-inputs-type-mismatch.yml @@ -0,0 +1,38 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 + with: + some-boolean: not-a-boolean + some-number: not-a-number +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: + workflow_call: + inputs: + some-boolean: + type: boolean + some-number: + type: number +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-job-inputs-type-mismatch.yml (Line: 6, Col: 21): Unexpected value 'not-a-boolean'" + }, + { + "Message": ".github/workflows/errors-reusable-workflow-job-inputs-type-mismatch.yml (Line: 7, Col: 20): Unexpected value 'not-a-number'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-inputs-undefined.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-inputs-undefined.yml new file mode 100644 index 0000000..7611a4c --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-inputs-undefined.yml @@ -0,0 +1,28 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 + with: + foo: bar +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: workflow_call +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-job-inputs-undefined.yml (Line: 6, Col: 12): Invalid input, foo is not defined in the referenced workflow." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-depth-exceeded.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-depth-exceeded.yml new file mode 100644 index 0000000..60f9db6 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-depth-exceeded.yml @@ -0,0 +1,41 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +max-nested-reusable-workflows-depth: 2 +--- +on: push +jobs: + deploy-level-0: + uses: contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-1: + uses: contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-2: + uses: contoso/templates/.github/workflows/deploy-level-3.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-3.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-3: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": "Nested reusable workflow depth exceeded 2." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-job-level-from-caller-job-level.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-job-level-from-caller-job-level.yml new file mode 100644 index 0000000..a5b1ce3 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-job-level-from-caller-job-level.yml @@ -0,0 +1,39 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +permissions: + actions: write +on: push +jobs: + deploy-level-0: + uses: contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-1: + permissions: + actions: read + uses: contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-2: + permissions: + actions: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": "contoso/templates/.github/workflows/deploy-level-1.yml@v1 (Line: 3, Col: 3): Error calling workflow 'contoso/templates/.github/workflows/deploy-level-2.yml@v1'. The nested job 'deploy-level-2' is requesting 'actions: write', but is only allowed 'actions: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-job-level-from-caller-workflow-level.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-job-level-from-caller-workflow-level.yml new file mode 100644 index 0000000..e8e74bf --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-job-level-from-caller-workflow-level.yml @@ -0,0 +1,39 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +permissions: + actions: write +on: push +jobs: + deploy-level-0: + uses: contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +permissions: + actions: read +on: workflow_call +jobs: + deploy-level-1: + uses: contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-2: + permissions: + actions: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": "contoso/templates/.github/workflows/deploy-level-1.yml@v1 (Line: 5, Col: 3): Error calling workflow 'contoso/templates/.github/workflows/deploy-level-2.yml@v1'. The nested job 'deploy-level-2' is requesting 'actions: write', but is only allowed 'actions: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-workflow-level-from-caller-job-level.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-workflow-level-from-caller-job-level.yml new file mode 100644 index 0000000..40a20a1 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-workflow-level-from-caller-job-level.yml @@ -0,0 +1,39 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +permissions: + actions: write +on: push +jobs: + deploy-level-0: + uses: contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-1: + permissions: + actions: read + uses: contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +on: workflow_call +permissions: + actions: write +jobs: + deploy-level-2: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": "contoso/templates/.github/workflows/deploy-level-1.yml@v1 (Line: 3, Col: 3): Error calling workflow 'contoso/templates/.github/workflows/deploy-level-2.yml@v1'. The workflow 'contoso/templates/.github/workflows/deploy-level-2.yml@v1' is requesting 'actions: write', but is only allowed 'actions: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-workflow-level-from-caller-workflow-level.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-workflow-level-from-caller-workflow-level.yml new file mode 100644 index 0000000..d92939f --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-nested-permissions-not-allowed-workflow-level-from-caller-workflow-level.yml @@ -0,0 +1,39 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +permissions: + actions: write +on: push +jobs: + deploy-level-0: + uses: contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +permissions: + actions: read +on: workflow_call +jobs: + deploy-level-1: + uses: contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +on: workflow_call +permissions: + actions: write +jobs: + deploy-level-2: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": "contoso/templates/.github/workflows/deploy-level-1.yml@v1 (Line: 5, Col: 3): Error calling workflow 'contoso/templates/.github/workflows/deploy-level-2.yml@v1'. The workflow 'contoso/templates/.github/workflows/deploy-level-2.yml@v1' is requesting 'actions: write', but is only allowed 'actions: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets-required.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets-required.yml new file mode 100644 index 0000000..6015a83 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets-required.yml @@ -0,0 +1,30 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: + workflow_call: + secrets: + shh: + required: true +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-job-secrets-required.yml (Line: 4, Col: 11): Secret shh is required, but not provided while calling." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets-undefined.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets-undefined.yml new file mode 100644 index 0000000..97ad0e7 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets-undefined.yml @@ -0,0 +1,28 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 + secrets: + foo: bar +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: workflow_call +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-job-secrets-undefined.yml (Line: 6, Col: 12): Invalid secret, foo is not defined in the referenced workflow." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets.yml new file mode 100644 index 0000000..b812f32 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets.yml @@ -0,0 +1,44 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + deploy: + needs: build + uses: contoso/templates/.github/workflows/deploy.yml@v1 + with: + app_name: my app + secrets: inhrit # typo +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: + workflow_call: + inputs: + app_name: + required: true + type: string + secrets: + shh: + required: true +jobs: + job1: + runs-on: ubuntu-latest + outputs: + output1: ${{ steps.step1.outputs.test }} + steps: + - run: echo \""::set-output name=test::hello\"" +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-job-secrets.yml (Line: 12, Col: 14): Unexpected value 'inhrit'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-max-result-size.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-max-result-size.yml new file mode 100644 index 0000000..1b32531 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-max-result-size.yml @@ -0,0 +1,30 @@ +include-source: false # Drop file/line/col from output +max-result-size: 2048 +skip: + - TypeScript + - Go +--- +on: push +jobs: + build-0: { uses: contoso/templates/.github/workflows/deploy.yml@v1 } + build-1: { uses: contoso/templates/.github/workflows/deploy.yml@v1 } +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: workflow_call +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo Deploying... +--- +{ + "errors": [ + { + "Message": "contoso/templates/.github/workflows/deploy.yml@v1: Maximum object size exceeded" + }, + { + "Message": "Unexpected type '' encountered while reading 'root'. The type 'MappingToken' was expected." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-default.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-default.yml new file mode 100644 index 0000000..4051c23 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-default.yml @@ -0,0 +1,29 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: workflow_call +jobs: + deploy: + permissions: + actions: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-default.yml (Line: 3, Col: 3): Error calling workflow 'contoso/templates/.github/workflows/deploy.yml@v1'. The nested job 'deploy' is requesting 'actions: write', but is only allowed 'actions: none'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-job-level.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-job-level.yml new file mode 100644 index 0000000..f77fb2a --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-job-level.yml @@ -0,0 +1,30 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + deploy: + permissions: + actions: read + uses: contoso/templates/.github/workflows/deploy.yml@v1 +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: workflow_call +jobs: + deploy: + permissions: + actions: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-job-level.yml (Line: 3, Col: 3): Error calling workflow 'contoso/templates/.github/workflows/deploy.yml@v1'. The nested job 'deploy' is requesting 'actions: write', but is only allowed 'actions: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-workflow-level.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-workflow-level.yml new file mode 100644 index 0000000..9fc4cb6 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-workflow-level.yml @@ -0,0 +1,30 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +permissions: + actions: read +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: workflow_call +jobs: + deploy: + permissions: + actions: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-job-level-from-caller-workflow-level.yml (Line: 5, Col: 3): Error calling workflow 'contoso/templates/.github/workflows/deploy.yml@v1'. The nested job 'deploy' is requesting 'actions: write', but is only allowed 'actions: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-actions-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-actions-write.yml new file mode 100644 index 0000000..aee494b --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-actions-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + actions: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + actions: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-actions-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'actions: write', but is only allowed 'actions: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-checks-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-checks-write.yml new file mode 100644 index 0000000..19243c4 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-checks-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + checks: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + checks: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-checks-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'checks: write', but is only allowed 'checks: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-contents-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-contents-write.yml new file mode 100644 index 0000000..52603d1 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-contents-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + contents: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-contents-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'contents: write', but is only allowed 'contents: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-deployments-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-deployments-write.yml new file mode 100644 index 0000000..4d3fcd3 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-deployments-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + deployments: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + deployments: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-deployments-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'deployments: write', but is only allowed 'deployments: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-discussions-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-discussions-write.yml new file mode 100644 index 0000000..72473e7 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-discussions-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + discussions: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + discussions: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-discussions-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'discussions: write', but is only allowed 'discussions: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-id-token-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-id-token-write.yml new file mode 100644 index 0000000..1d4f128 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-id-token-write.yml @@ -0,0 +1,44 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + actions: write + checks: write + contents: write + deployments: write + discussions: write + issues: write + packages: write + pages: write + pull-requests: write + repository-projects: write + security-events: write + statuses: write + id-token: none + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + id-token: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-id-token-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'id-token: write', but is only allowed 'id-token: none'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-issues-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-issues-write.yml new file mode 100644 index 0000000..3cb7d0a --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-issues-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + issues: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + issues: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-issues-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'issues: write', but is only allowed 'issues: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-packages-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-packages-write.yml new file mode 100644 index 0000000..1e6c9c4 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-packages-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + packages: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + packages: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-packages-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'packages: write', but is only allowed 'packages: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-pages-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-pages-write.yml new file mode 100644 index 0000000..babdc6b --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-pages-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + pages: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + pages: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-pages-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'pages: write', but is only allowed 'pages: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-pull-requests-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-pull-requests-write.yml new file mode 100644 index 0000000..68c3a23 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-pull-requests-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + pull-requests: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + pull-requests: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-pull-requests-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'pull-requests: write', but is only allowed 'pull-requests: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-read-all-allowed-none.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-read-all-allowed-none.yml new file mode 100644 index 0000000..5f520bc --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-read-all-allowed-none.yml @@ -0,0 +1,31 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +# Note, the workflow names are intentionally short so the error message doesn't get truncated too much +--- +on: push +jobs: + deploy: + permissions: {} + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: read-all + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-read-all-allowed-none.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'actions: read, checks: read, contents: read, deployments: read, discussions: read, issues: read, packages: read, pages: read, pull-requests: read, repository-projects: read, statuses: read, security-events: read, id-token: read', but is only allowed 'actions: none, checks: none, co[...]" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-repository-projects-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-repository-projects-write.yml new file mode 100644 index 0000000..7df1abc --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-repository-projects-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + repository-projects: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + repository-projects: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-repository-projects-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'repository-projects: write', but is only allowed 'repository-projects: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-security-events-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-security-events-write.yml new file mode 100644 index 0000000..d7081ae --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-security-events-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + security-events: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + security-events: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-security-events-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'security-events: write', but is only allowed 'security-events: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-statuses-write.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-statuses-write.yml new file mode 100644 index 0000000..2aebcc9 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-statuses-write.yml @@ -0,0 +1,32 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + permissions: + statuses: read + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: + statuses: write + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-statuses-write.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'statuses: write', but is only allowed 'statuses: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-write-all-allowed-none.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-write-all-allowed-none.yml new file mode 100644 index 0000000..98a4844 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-write-all-allowed-none.yml @@ -0,0 +1,31 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +# Note, the workflow names are intentionally short so the error message doesn't get truncated too much +--- +on: push +jobs: + deploy: + permissions: {} + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: write-all + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-write-all-allowed-none.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'actions: write, checks: write, contents: write, deployments: write, discussions: write, issues: write, packages: write, pages: write, pull-requests: write, repository-projects: write, statuses: write, security-events: write, id-token: write', but is only allowed 'actions: none, ch[...]" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-write-all-allowed-read-all.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-write-all-allowed-read-all.yml new file mode 100644 index 0000000..9d5ca02 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-request-write-all-allowed-read-all.yml @@ -0,0 +1,31 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +# Note, the workflow names are intentionally short so the error message doesn't get truncated too much +--- +on: push +jobs: + deploy: + permissions: read-all + uses: a/b/.github/workflows/c.yml@v1 +--- +a/b/.github/workflows/c.yml@v1 +--- +on: workflow_call +jobs: + deploy: + name: Deploy 1 + permissions: write-all + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-request-write-all-allowed-read-all.yml (Line: 3, Col: 3): Error calling workflow 'a/b/.github/workflows/c.yml@v1'. The nested job 'Deploy 1' is requesting 'actions: write, checks: write, contents: write, deployments: write, discussions: write, issues: write, packages: write, pages: write, pull-requests: write, repository-projects: write, statuses: write, security-events: write, id-token: write', but is only allowed 'actions: read[...]" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-default.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-default.yml new file mode 100644 index 0000000..67ba05a --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-default.yml @@ -0,0 +1,29 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: workflow_call +permissions: + actions: write +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-default.yml (Line: 3, Col: 3): Error calling workflow 'contoso/templates/.github/workflows/deploy.yml@v1'. The workflow 'contoso/templates/.github/workflows/deploy.yml@v1' is requesting 'actions: write', but is only allowed 'actions: none'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-job-level.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-job-level.yml new file mode 100644 index 0000000..61865f7 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-job-level.yml @@ -0,0 +1,30 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + deploy: + permissions: + actions: read + uses: contoso/templates/.github/workflows/deploy.yml@v1 +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: workflow_call +permissions: + actions: write +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-job-level.yml (Line: 3, Col: 3): Error calling workflow 'contoso/templates/.github/workflows/deploy.yml@v1'. The workflow 'contoso/templates/.github/workflows/deploy.yml@v1' is requesting 'actions: write', but is only allowed 'actions: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-workflow-level.yml b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-workflow-level.yml new file mode 100644 index 0000000..b414ccb --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-workflow-level.yml @@ -0,0 +1,30 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +permissions: + actions: read +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: workflow_call +permissions: + actions: write +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-reusable-workflow-permissions-not-allowed-workflow-level-from-caller-workflow-level.yml (Line: 5, Col: 3): Error calling workflow 'contoso/templates/.github/workflows/deploy.yml@v1'. The workflow 'contoso/templates/.github/workflows/deploy.yml@v1' is requesting 'actions: write', but is only allowed 'actions: read'." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-step-id.yml b/actions-workflow-parser/testdata/reader/errors-step-id.yml new file mode 100644 index 0000000..d980321 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-step-id.yml @@ -0,0 +1,66 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +name: CI +on: + push: +jobs: + build: + runs-on: [ self-hosted ] + steps: + # Valid + - run: echo no-id # no id + - id: valid-id + run: echo valid-id + - id: _ + run: echo _ + - id: _a + run: echo _a + - id: a__ + run: echo a__ + + # Duplicate + - id: my-step + run: echo my-step + - id: my-step + run: echo my-step-duplicate + + # Duplicate (case insensitive) + - id: step1 + run: echo step1 + - id: STEP1 + run: echo STEP1 + + # Invalid + - id: _invalid$stuff + run: echo _invalid$stuff + - id: -invalid-step! + run: echo -invalid-step! + - id: __ + run: echo __ + - id: __a + run: echo __a +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-step-id.yml (Line: 22, Col: 13): The identifier 'my-step' may not be used more than once within the same scope." + }, + { + "Message": ".github/workflows/errors-step-id.yml (Line: 28, Col: 13): The identifier 'STEP1' may not be used more than once within the same scope." + }, + { + "Message": ".github/workflows/errors-step-id.yml (Line: 32, Col: 13): The identifier '_invalid$stuff' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters." + }, + { + "Message": ".github/workflows/errors-step-id.yml (Line: 34, Col: 13): The identifier '-invalid-step!' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than 100 characters." + }, + { + "Message": ".github/workflows/errors-step-id.yml (Line: 36, Col: 13): The identifier '__' is invalid. IDs starting with '__' are reserved." + }, + { + "Message": ".github/workflows/errors-step-id.yml (Line: 38, Col: 13): The identifier '__a' is invalid. IDs starting with '__' are reserved." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-step-if.yml b/actions-workflow-parser/testdata/reader/errors-step-if.yml new file mode 100644 index 0000000..a46c1e8 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-step-if.yml @@ -0,0 +1,19 @@ +include-source: false # Drop file/line/col from output +--- +on: + push: +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + if: (steps.publish-pdf.outcome != 'Skipped' || steps.publish-json.outcome != 'Skipped) && success() + run: echo "Hello" +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-step-if.yml (Line: 8, Col: 15): Unexpected symbol: ''Skipped) && success()'. Located at position 74 within expression: (steps.publish-pdf.outcome != 'Skipped' || steps.publish-json.outcome != 'Skipped) && success()" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-step-run-exceeds-length.yml b/actions-workflow-parser/testdata/reader/errors-step-run-exceeds-length.yml new file mode 100644 index 0000000..8933371 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-step-run-exceeds-length.yml @@ -0,0 +1,47 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + # Max expression length is 21000 + - run: | + echo ${{ github.sha }} + + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + + # 1000 chars: 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-step-run-exceeds-length.yml (Line: 7, Col: 14): Exceeded max expression length 21000" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-step-run-missing.yml b/actions-workflow-parser/testdata/reader/errors-step-run-missing.yml new file mode 100644 index 0000000..e312b23 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-step-run-missing.yml @@ -0,0 +1,16 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - shell: foo +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-step-run-missing.yml (Line: 6, Col: 9): Required property is missing: run" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-step-uses-missing.yml b/actions-workflow-parser/testdata/reader/errors-step-uses-missing.yml new file mode 100644 index 0000000..68c273c --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-step-uses-missing.yml @@ -0,0 +1,17 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - with: + foo: bar +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-step-uses-missing.yml (Line: 6, Col: 9): Required property is missing: uses" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-step-uses-syntax.yml b/actions-workflow-parser/testdata/reader/errors-step-uses-syntax.yml new file mode 100644 index 0000000..3b244e2 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-step-uses-syntax.yml @@ -0,0 +1,33 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + # Valid + - uses: docker://alpine:3.8 + - uses: actions/aws/ec2@main + - uses: ./.github/actions/my-action + + # Invalid + - uses: $$docker://alpine:3.8 + - uses: ...docker://alpine:3.8 + - uses: badrepo@invalid + - uses: docker:// +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-step-uses-syntax.yml (Line: 12, Col: 15): Expected format {org}/{repo}[/path]@ref. Actual '$$docker://alpine:3.8'" + }, + { + "Message": ".github/workflows/errors-step-uses-syntax.yml (Line: 13, Col: 15): Expected format {org}/{repo}[/path]@ref. Actual '...docker://alpine:3.8'" + }, + { + "Message": ".github/workflows/errors-step-uses-syntax.yml (Line: 14, Col: 15): Expected format {org}/{repo}[/path]@ref. Actual 'badrepo@invalid'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-unclosed-tokens.yml b/actions-workflow-parser/testdata/reader/errors-unclosed-tokens.yml new file mode 100644 index 0000000..e814dce --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-unclosed-tokens.yml @@ -0,0 +1,20 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + if: ${{ contains('a', 'b' }} + runs-on: ubuntu-latest + steps: + - run: echo hi + +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-unclosed-tokens.yml (Line: 4, Col: 9): Unexpected end of expression: ''b''. Located at position 15 within expression: contains('a', 'b'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-unexpected-mapping.yml b/actions-workflow-parser/testdata/reader/errors-unexpected-mapping.yml new file mode 100644 index 0000000..5482655 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-unexpected-mapping.yml @@ -0,0 +1,18 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + not: a-sequence +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-unexpected-mapping.yml (Line: 6, Col: 7): A mapping was not expected" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-unexpected-sequence.yml b/actions-workflow-parser/testdata/reader/errors-unexpected-sequence.yml new file mode 100644 index 0000000..5157928 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-unexpected-sequence.yml @@ -0,0 +1,20 @@ +include-source: false # Drop file/line/col from output +--- +name: + - no + - sequence + - here +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-unexpected-sequence.yml (Line: 2, Col: 3): A sequence was not expected" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-value-already-defined.yml b/actions-workflow-parser/testdata/reader/errors-value-already-defined.yml new file mode 100644 index 0000000..f527298 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-value-already-defined.yml @@ -0,0 +1,17 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + runs-on: duplicate-key + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-value-already-defined.yml (Line: 5, Col: 5): 'runs-on' is already defined" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-yaml-invalid-style.yml b/actions-workflow-parser/testdata/reader/errors-yaml-invalid-style.yml new file mode 100644 index 0000000..13317c0 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-yaml-invalid-style.yml @@ -0,0 +1,19 @@ +include-source: false # Drop file/line/col from output +skip: +- TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + cancel-timeout-minutes: !!int "300" + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-yaml-invalid-style.yml: The scalar style 'DoubleQuoted' on line 5 and column 29 is not valid with the tag 'tag:yaml.org,2002:int'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-yaml-tags-explicit-unsupported.yml b/actions-workflow-parser/testdata/reader/errors-yaml-tags-explicit-unsupported.yml new file mode 100644 index 0000000..cedd835 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-yaml-tags-explicit-unsupported.yml @@ -0,0 +1,20 @@ +include-source: false # Drop file/line/col from output +skip: +- TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: github/issue-labeler@v2.5 + with: + not-before: !!timestamp 2022-10-26T00:00:00Z # explicitly set unsupported tag +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-yaml-tags-explicit-unsupported.yml: Unexpected tag 'tag:yaml.org,2002:timestamp'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/escape-html-values.yml b/actions-workflow-parser/testdata/reader/escape-html-values.yml new file mode 100644 index 0000000..5138f67 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/escape-html-values.yml @@ -0,0 +1,80 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +env: + time: < #mapping token +jobs: + build: + if: false || ${{ true && true }} #expression token + runs-on: [macos-latest,linux,self-hosted, <] #sequence token + steps: + - run: echo Hello && World #string token + build2: + if: false + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +{ + "env": { + "type": 2, + "map": [ + { + "Key": "time", + "Value": "<" + } + ] + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success() && (format('false || {0}', true && true))" + }, + "runs-on": { + "type": 1, + "seq": [ + "macos-latest", + "linux", + "self-hosted", + "<" + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo Hello && World" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success() && (false)" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/events-mapping-all.yml b/actions-workflow-parser/testdata/reader/events-mapping-all.yml new file mode 100644 index 0000000..70560a1 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/events-mapping-all.yml @@ -0,0 +1,554 @@ +skip: + - C# + - Go +--- +on: + branch_protection_rule: + types: + - created + - edited + - deleted + check_run: + types: + - created + - completed + - requested_action + - rerequested + check_suite: + types: + - completed + create: + delete: + deployment: + deployment_status: + discussion: + types: + - created + - edited + - deleted + - transferred + - pinned + - unpinned + - labeled + - unlabeled + - locked + - unlocked + - category_changed + - answered + - unanswered + discussion_comment: + types: + - created + - edited + - deleted + fork: + gollum: + issue_comment: + types: + - created + - edited + - deleted + issues: + types: + - opened + - edited + - deleted + - transferred + - pinned + - unpinned + - closed + - reopened + - assigned + - unassigned + - labeled + - unlabeled + - locked + - unlocked + - milestoned + - demilestoned + label: + types: + - created + - edited + - deleted + merge_group: + types: checks_requested + milestone: + types: + - created + - closed + - opened + - edited + - deleted + page_build: + project: + types: + - created + - closed + - opened + - edited + - deleted + project_card: + types: + - created + - moved + - converted + - edited + - deleted + project_column: + types: + - created + - updated + - moved + - deleted + public: + pull_request: + branches: + - master + - 'main' + branches-ignore: [ develop ] + paths: file + paths-ignore: 'file' + types: + - assigned + - unassigned + - labeled + - unlabeled + - opened + - edited + - closed + - reopened + - synchronize + - converted_to_draft + - ready_for_review + - locked + - unlocked + - review_requested + - review_request_removed + - auto_merge_enabled + - auto_merge_disabled + pull_request_comment: + types: + - created + - edited + - deleted + pull_request_review: + types: + - submitted + - edited + - dismissed + pull_request_review_comment: + types: + - created + - edited + - deleted + pull_request_target: + branches: + - master + - 'main' + branches-ignore: [ develop ] + paths: file + paths-ignore: 'file' + types: + - assigned + - unassigned + - labeled + - unlabeled + - opened + - edited + - closed + - reopened + - synchronize + - converted_to_draft + - ready_for_review + - locked + - unlocked + - review_requested + - review_request_removed + - auto_merge_enabled + - auto_merge_disabled + push: + branches: + - master + - 'main' + branches-ignore: [ develop ] + tags: + - v1 + - 'v2' + tags-ignore: [ 'v3' ] + paths: file + paths-ignore: 'file' + registry_package: + types: + - published + - updated + release: + types: + - published + - unpublished + - created + - edited + - deleted + - prereleased + - released + schedule: + - cron: "* * * * 5" + - cron: "* * * * 6" + status: + watch: + types: started + workflow_call: + inputs: + foo: + type: string + description: 'Foo' + required: true + default: 'bar' + workflow_run: + workflows: ci + types: + - requested + - completed + - in_progress + branches: + - master + - 'main' + branches-ignore: [ develop ] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "events": { + "branch_protection_rule": { + "types": [ + "created", + "edited", + "deleted" + ] + }, + "check_run": { + "types": [ + "created", + "completed", + "requested_action", + "rerequested" + ] + }, + "check_suite": { + "types": [ + "completed" + ] + }, + "create": {}, + "delete": {}, + "deployment": {}, + "deployment_status": {}, + "discussion": { + "types": [ + "created", + "edited", + "deleted", + "transferred", + "pinned", + "unpinned", + "labeled", + "unlabeled", + "locked", + "unlocked", + "category_changed", + "answered", + "unanswered" + ] + }, + "discussion_comment": { + "types": [ + "created", + "edited", + "deleted" + ] + }, + "fork": {}, + "gollum": {}, + "issue_comment": { + "types": [ + "created", + "edited", + "deleted" + ] + }, + "issues": { + "types": [ + "opened", + "edited", + "deleted", + "transferred", + "pinned", + "unpinned", + "closed", + "reopened", + "assigned", + "unassigned", + "labeled", + "unlabeled", + "locked", + "unlocked", + "milestoned", + "demilestoned" + ] + }, + "label": { + "types": [ + "created", + "edited", + "deleted" + ] + }, + "merge_group": { + "types": [ + "checks_requested" + ] + }, + "milestone": { + "types": [ + "created", + "closed", + "opened", + "edited", + "deleted" + ] + }, + "page_build": {}, + "project": { + "types": [ + "created", + "closed", + "opened", + "edited", + "deleted" + ] + }, + "project_card": { + "types": [ + "created", + "moved", + "converted", + "edited", + "deleted" + ] + }, + "project_column": { + "types": [ + "created", + "updated", + "moved", + "deleted" + ] + }, + "public": {}, + "pull_request": { + "branches": [ + "master", + "main" + ], + "branches-ignore": [ + "develop" + ], + "paths": [ + "file" + ], + "paths-ignore": [ + "file" + ], + "types": [ + "assigned", + "unassigned", + "labeled", + "unlabeled", + "opened", + "edited", + "closed", + "reopened", + "synchronize", + "converted_to_draft", + "ready_for_review", + "locked", + "unlocked", + "review_requested", + "review_request_removed", + "auto_merge_enabled", + "auto_merge_disabled" + ] + }, + "pull_request_comment": { + "types": [ + "created", + "edited", + "deleted" + ] + }, + "pull_request_review": { + "types": [ + "submitted", + "edited", + "dismissed" + ] + }, + "pull_request_review_comment": { + "types": [ + "created", + "edited", + "deleted" + ] + }, + "pull_request_target": { + "branches": [ + "master", + "main" + ], + "branches-ignore": [ + "develop" + ], + "paths": [ + "file" + ], + "paths-ignore": [ + "file" + ], + "types": [ + "assigned", + "unassigned", + "labeled", + "unlabeled", + "opened", + "edited", + "closed", + "reopened", + "synchronize", + "converted_to_draft", + "ready_for_review", + "locked", + "unlocked", + "review_requested", + "review_request_removed", + "auto_merge_enabled", + "auto_merge_disabled" + ] + }, + "push": { + "branches": [ + "master", + "main" + ], + "branches-ignore": [ + "develop" + ], + "tags": [ + "v1", + "v2" + ], + "tags-ignore": [ + "v3" + ], + "paths": [ + "file" + ], + "paths-ignore": [ + "file" + ] + }, + "registry_package": { + "types": [ + "published", + "updated" + ] + }, + "release": { + "types": [ + "published", + "unpublished", + "created", + "edited", + "deleted", + "prereleased", + "released" + ] + }, + "schedule": [ + { + "cron": "* * * * 5" + }, + { + "cron": "* * * * 6" + } + ], + "status": {}, + "watch": { + "types": [ + "started" + ] + }, + "workflow_call": { + "inputs": { + "foo": { + "type": "string", + "description": "Foo", + "required": true, + "default": "bar" + } + } + }, + "workflow_run": { + "branches": [ + "master", + "main" + ], + "branches-ignore": [ + "develop" + ], + "types": [ + "requested", + "completed", + "in_progress" + ], + "workflows": [ + "ci" + ] + } + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/events-mapping-repo-dispatch.yml b/actions-workflow-parser/testdata/reader/events-mapping-repo-dispatch.yml new file mode 100644 index 0000000..6a2ae2a --- /dev/null +++ b/actions-workflow-parser/testdata/reader/events-mapping-repo-dispatch.yml @@ -0,0 +1,53 @@ +skip: + - C# + - Go +--- +on: + repository_dispatch: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "events": { + "repository_dispatch": {}, + "workflow_dispatch": {} + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/events-mapping.yml b/actions-workflow-parser/testdata/reader/events-mapping.yml new file mode 100644 index 0000000..1e899ad --- /dev/null +++ b/actions-workflow-parser/testdata/reader/events-mapping.yml @@ -0,0 +1,96 @@ +skip: + - C# + - Go +--- +on: + repository_dispatch: + types: [custom-type] + workflow_dispatch: + inputs: + name: + type: string + default: monalisa + description: Name to greet + send_emojis: + type: boolean + default: true + greeting: + type: choice + default: hello + options: + - hello + - hallo + env: + type: environment +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "events": { + "repository_dispatch": { + "types": [ + "custom-type" + ] + }, + "workflow_dispatch": { + "inputs": { + "name": { + "type": "string", + "description": "Name to greet", + "default": "monalisa" + }, + "send_emojis": { + "type": "boolean", + "default": true + }, + "greeting": { + "type": "choice", + "options": [ + "hello", + "hallo" + ], + "default": "hello" + }, + "env": { + "type": "environment" + } + } + } + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/events-sequence.yml b/actions-workflow-parser/testdata/reader/events-sequence.yml new file mode 100644 index 0000000..0b477fb --- /dev/null +++ b/actions-workflow-parser/testdata/reader/events-sequence.yml @@ -0,0 +1,51 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - C# +--- +on: [push, pull_request] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "events": { + "push": {}, + "pull_request": {} + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/events-single-invalid-config.yml b/actions-workflow-parser/testdata/reader/events-single-invalid-config.yml new file mode 100644 index 0000000..0e73142 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/events-single-invalid-config.yml @@ -0,0 +1,25 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - C# +--- +on: + push: + inputs: + name: + type: string +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "errors": [ + { + "Message": ".github/workflows/events-single-invalid-config.yml (Line: 3, Col: 5): Unexpected value 'inputs'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/events-single-invalid.yml b/actions-workflow-parser/testdata/reader/events-single-invalid.yml new file mode 100644 index 0000000..5dea5c1 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/events-single-invalid.yml @@ -0,0 +1,29 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - C# +--- +on: + workflow_dispatch: + unknown_value: test + inputs: + name: + another_unknown_value: 123 +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "errors": [ + { + "Message": ".github/workflows/events-single-invalid.yml (Line: 3, Col: 5): Unexpected value 'unknown_value'" + }, + { + "Message": ".github/workflows/events-single-invalid.yml (Line: 6, Col: 9): Unexpected value 'another_unknown_value'" + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/events-single-with-types.yml b/actions-workflow-parser/testdata/reader/events-single-with-types.yml new file mode 100644 index 0000000..c61a678 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/events-single-with-types.yml @@ -0,0 +1,56 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - C# +--- +on: + pull_request: + types: synchronize +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "events": { + "pull_request": { + "types": [ + "synchronize" + ] + } + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/events-single.yml b/actions-workflow-parser/testdata/reader/events-single.yml new file mode 100644 index 0000000..23c3a5b --- /dev/null +++ b/actions-workflow-parser/testdata/reader/events-single.yml @@ -0,0 +1,50 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - C# +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "events": { + "push": {} + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/id-to-long.yml b/actions-workflow-parser/testdata/reader/id-to-long.yml new file mode 100644 index 0000000..fcc8f23 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/id-to-long.yml @@ -0,0 +1,42 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: docker://chinthakagodawita/autoupdate-action@sha256:a3e234f9fce69dd9b3a205acfd55bf9d5c94f0f7cf119f0267a5ab54220e8f56 # v1 + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__chinthakagodawita_autoupdate-action_sha256_a3e234f9fce69dd9b3a205acfd55bf9d5c94f0f7cf119f0267a5ab5", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "docker://chinthakagodawita/autoupdate-action@sha256:a3e234f9fce69dd9b3a205acfd55bf9d5c94f0f7cf119f0267a5ab54220e8f56" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/job-basic.yml b/actions-workflow-parser/testdata/reader/job-basic.yml new file mode 100644 index 0000000..824c814 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-basic.yml @@ -0,0 +1,61 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + job1: + runs-on: windows-2019 + steps: + - run: echo 1 + job2: + runs-on: windows-2019 + cancel-timeout-minutes: 5 + steps: + - run: echo 2 +--- +{ + "jobs": [ + { + "type": "job", + "id": "job1", + "name": "job1", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "windows-2019", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + }, + { + "type": "job", + "id": "job2", + "name": "job2", + "if": { + "type": 3, + "expr": "success()" + }, + "cancel-timeout-minutes": 5, + "runs-on": "windows-2019", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 2" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-cancel-timeout-minutes.yml b/actions-workflow-parser/testdata/reader/job-cancel-timeout-minutes.yml new file mode 100644 index 0000000..632dc91 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-cancel-timeout-minutes.yml @@ -0,0 +1,124 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + cancel-timeout-minutes: 123 + steps: + - run: echo hi + build2: + strategy: + matrix: + include: + - runs-on: ubuntu-latest + cancel-timeout-minutes: 5 + - runs-on: windows-latest + cancel-timeout-minutes: 6 + runs-on: ${{ matrix.runs-on }} + cancel-timeout-minutes: ${{ matrix.cancel-timeout-minutes }} + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "cancel-timeout-minutes": 123, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "include", + "Value": { + "type": 1, + "seq": [ + { + "type": 2, + "map": [ + { + "Key": "runs-on", + "Value": "ubuntu-latest" + }, + { + "Key": "cancel-timeout-minutes", + "Value": 5 + } + ] + }, + { + "type": 2, + "map": [ + { + "Key": "runs-on", + "Value": "windows-latest" + }, + { + "Key": "cancel-timeout-minutes", + "Value": 6 + } + ] + } + ] + } + } + ] + } + } + ] + }, + "cancel-timeout-minutes": { + "type": 3, + "expr": "matrix.cancel-timeout-minutes" + }, + "runs-on": { + "type": 3, + "expr": "matrix.runs-on" + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-concurrency.yml b/actions-workflow-parser/testdata/reader/job-concurrency.yml new file mode 100644 index 0000000..4a9d711 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-concurrency.yml @@ -0,0 +1,124 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + concurrency: foo + steps: + - run: echo hi + build2: + strategy: + matrix: + include: + - runs-on: ubuntu-latest + concurrency: ubuntu + - runs-on: windows-latest + concurrency: windows + runs-on: ${{ matrix.runs-on }} + cancel-timeout-minutes: ${{ matrix.concurrency }} + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "concurrency": "foo", + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "include", + "Value": { + "type": 1, + "seq": [ + { + "type": 2, + "map": [ + { + "Key": "runs-on", + "Value": "ubuntu-latest" + }, + { + "Key": "concurrency", + "Value": "ubuntu" + } + ] + }, + { + "type": 2, + "map": [ + { + "Key": "runs-on", + "Value": "windows-latest" + }, + { + "Key": "concurrency", + "Value": "windows" + } + ] + } + ] + } + } + ] + } + } + ] + }, + "cancel-timeout-minutes": { + "type": 3, + "expr": "matrix.concurrency" + }, + "runs-on": { + "type": 3, + "expr": "matrix.runs-on" + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-container-invalid-credentials.yml b/actions-workflow-parser/testdata/reader/job-container-invalid-credentials.yml new file mode 100644 index 0000000..cf1e384 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-container-invalid-credentials.yml @@ -0,0 +1,20 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: linux + container: + image: node:14.16 + credentials: + badkey: somevalue + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/job-container-invalid-credentials.yml (Line: 8, Col: 9): Unexpected value 'badkey'" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/job-container-invalid.yml b/actions-workflow-parser/testdata/reader/job-container-invalid.yml new file mode 100644 index 0000000..0469cf8 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-container-invalid.yml @@ -0,0 +1,49 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build1: + runs-on: linux + container: + image: node:14.16 + credentials: + badkey: somevalue + steps: + - run: echo hi + build2: + runs-on: linux + container: + image: + steps: + - run: echo hi + build3: + runs-on: linux + services: + servicename: + badkey: somevalue + steps: + - run: echo hi + build4: + runs-on: linux + services: + servicename: + image: + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/job-container-invalid.yml (Line: 8, Col: 9): Unexpected value 'badkey'" + }, + { + "Message": ".github/workflows/job-container-invalid.yml (Line: 14, Col: 13): Unexpected value ''" + }, + { + "Message": ".github/workflows/job-container-invalid.yml (Line: 21, Col: 9): Unexpected value 'badkey'" + }, + { + "Message": ".github/workflows/job-container-invalid.yml (Line: 28, Col: 15): Unexpected value ''" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/job-container-missing-image.yml b/actions-workflow-parser/testdata/reader/job-container-missing-image.yml new file mode 100644 index 0000000..64b3d83 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-container-missing-image.yml @@ -0,0 +1,28 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build1: + runs-on: linux + container: + options: someoption + steps: + - run: echo hi + build2: + runs-on: linux + services: + nginx: + options: someoption + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/job-container-missing-image.yml (Line: 6, Col: 7): Container image cannot be empty" + }, + { + "Message": ".github/workflows/job-container-missing-image.yml (Line: 13, Col: 9): Container image cannot be empty" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/job-container.yml b/actions-workflow-parser/testdata/reader/job-container.yml new file mode 100644 index 0000000..49e1741 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-container.yml @@ -0,0 +1,199 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + # Short form + build: + runs-on: linux + container: ubuntu:16.04 + steps: + - run: echo hi + # Long form + build2: + runs-on: linux + container: + image: node:14.16 + env: + NODE_ENV: development + ports: + - 80 + volumes: + - my_docker_volume:/volume_mount + options: --cpus 1 + credentials: + username: ${{ github.actor }} + password: ${{ secrets.github_token }} + steps: + - run: echo hi + # With credential + build3: + runs-on: linux + container: + image: private:latest + credentials: + username: ${{ env.FROM_ENV }} + password: ${{ secrets.FROM_SECRETS }} + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "linux", + "container": "ubuntu:16.04", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "linux", + "container": { + "type": 2, + "map": [ + { + "Key": "image", + "Value": "node:14.16" + }, + { + "Key": "env", + "Value": { + "type": 2, + "map": [ + { + "Key": "NODE_ENV", + "Value": "development" + } + ] + } + }, + { + "Key": "ports", + "Value": { + "type": 1, + "seq": [ + "80" + ] + } + }, + { + "Key": "volumes", + "Value": { + "type": 1, + "seq": [ + "my_docker_volume:/volume_mount" + ] + } + }, + { + "Key": "options", + "Value": "--cpus 1" + }, + { + "Key": "credentials", + "Value": { + "type": 2, + "map": [ + { + "Key": "username", + "Value": { + "type": 3, + "expr": "github.actor" + } + }, + { + "Key": "password", + "Value": { + "type": 3, + "expr": "secrets.github_token" + } + } + ] + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build3", + "name": "build3", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "linux", + "container": { + "type": 2, + "map": [ + { + "Key": "image", + "Value": "private:latest" + }, + { + "Key": "credentials", + "Value": { + "type": 2, + "map": [ + { + "Key": "username", + "Value": { + "type": 3, + "expr": "env.FROM_ENV" + } + }, + { + "Key": "password", + "Value": { + "type": 3, + "expr": "secrets.FROM_SECRETS" + } + } + ] + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-continue-on-error.yml b/actions-workflow-parser/testdata/reader/job-continue-on-error.yml new file mode 100644 index 0000000..6a09af9 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-continue-on-error.yml @@ -0,0 +1,166 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - run: echo hi + build2: + runs-on: ubuntu-latest + continue-on-error: false + steps: + - run: echo hi + buil3: + runs-on: ubuntu-latest + continue-on-error: ${{ startsWith(github.ref, 'refs/heads/experimental/') }} + steps: + - run: echo hi + build4: + strategy: + matrix: + include: + - cfg: experimental + - cfg: normal + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.cfg == 'experimental' }} + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": false, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "buil3", + "name": "buil3", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": { + "type": 3, + "expr": "startsWith(github.ref, 'refs/heads/experimental/')" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build4", + "name": "build4", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "include", + "Value": { + "type": 1, + "seq": [ + { + "type": 2, + "map": [ + { + "Key": "cfg", + "Value": "experimental" + } + ] + }, + { + "type": 2, + "map": [ + { + "Key": "cfg", + "Value": "normal" + } + ] + } + ] + } + } + ] + } + } + ] + }, + "continue-on-error": { + "type": 3, + "expr": "matrix.cfg == 'experimental'" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-defaults.yml b/actions-workflow-parser/testdata/reader/job-defaults.yml new file mode 100644 index 0000000..25e99dd --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-defaults.yml @@ -0,0 +1,63 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: linux + defaults: + run: + shell: cmd + working-directory: ${{github.test}} + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "defaults": { + "type": 2, + "map": [ + { + "Key": "run", + "Value": { + "type": 2, + "map": [ + { + "Key": "shell", + "Value": "cmd" + }, + { + "Key": "working-directory", + "Value": { + "type": 3, + "expr": "github.test" + } + } + ] + } + } + ] + }, + "runs-on": "linux", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-environment.yml b/actions-workflow-parser/testdata/reader/job-environment.yml new file mode 100644 index 0000000..3dc16a9 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-environment.yml @@ -0,0 +1,173 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + environment: prod + runs-on: ubuntu-latest + steps: + - run: echo hi + build2: + environment: '' + runs-on: ubuntu-latest + steps: + - run: echo hi + build3: + environment: + name: myenv + url: http://localhost + runs-on: ubuntu-latest + steps: + - run: echo hi + build4: + environment: ${{ inputs.env }} + runs-on: ubuntu-latest + steps: + - run: echo hi + build5: + environment: + name: ${{ inputs.env }} + url: http://localhost + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "environment": "prod", + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "environment": "", + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build3", + "name": "build3", + "if": { + "type": 3, + "expr": "success()" + }, + "environment": { + "type": 2, + "map": [ + { + "Key": "name", + "Value": "myenv" + }, + { + "Key": "url", + "Value": "http://localhost" + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build4", + "name": "build4", + "if": { + "type": 3, + "expr": "success()" + }, + "environment": { + "type": 3, + "expr": "inputs.env" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build5", + "name": "build5", + "if": { + "type": 3, + "expr": "success()" + }, + "environment": { + "type": 2, + "map": [ + { + "Key": "name", + "Value": { + "type": 3, + "expr": "inputs.env" + } + }, + { + "Key": "url", + "Value": "http://localhost" + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-id.yml b/actions-workflow-parser/testdata/reader/job-id.yml new file mode 100644 index 0000000..0bab4db --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-id.yml @@ -0,0 +1,153 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + _myjob: + runs-on: ubuntu-latest + steps: + - run: echo hi + _my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi + myjob-: + runs-on: ubuntu-latest + steps: + - run: echo hi + my-job0: + runs-on: ubuntu-latest + steps: + - run: echo hi + _my-job0: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "_myjob", + "name": "_myjob", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "_my-job", + "name": "_my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "myjob-", + "name": "myjob-", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "my-job0", + "name": "my-job0", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "_my-job0", + "name": "_my-job0", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-if.yml b/actions-workflow-parser/testdata/reader/job-if.yml new file mode 100644 index 0000000..bfa5a5b --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-if.yml @@ -0,0 +1,329 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +env: + time: 5 +jobs: + build: + if: true + runs-on: ubuntu-latest + steps: + - run: echo hi + build2: + if: "" + runs-on: ubuntu-latest + steps: + - run: echo hi + build3: + if: ${{ github.foo == 'bar' }} + runs-on: ubuntu-latest + steps: + - run: echo hi + build4: + needs: + - build + - build2 + if: ${{ success('build', 'build2') }} + runs-on: ubuntu-latest + steps: + - run: echo hi + build5: + if: github.foo == 'bar' + runs-on: ubuntu-latest + steps: + - run: echo hi + build6: + if: null + runs-on: ubuntu-latest + steps: + - run: echo hi + build7: + if: false || (always() && true) + runs-on: linux + steps: + - run: echo Hello World + build8: + if: false || (true && true) + runs-on: macos-latest + steps: + - run: echo Hello World + build9: + if: false && success() + runs-on: ubuntu-latest + steps: + - run: echo 1 + build10: + if: ${{ toJSON( github.actor) }} + runs-on: ubuntu-latest + steps: + - run: echo Hello World + build11: + runs-on: linux + steps: + - if: ${{always() && fromJSON(env.time) == 5 }} + run: echo Hello World + build12: + if: fromJson(toJSON(success())) + runs-on: linux + steps: + - run: echo Hello World +--- +{ + "env": { + "type": 2, + "map": [ + { + "Key": "time", + "Value": "5" + } + ] + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success() && (true)" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build3", + "name": "build3", + "if": { + "type": 3, + "expr": "success() && (github.foo == 'bar')" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build4", + "name": "build4", + "needs": [ + "build", + "build2" + ], + "if": { + "type": 3, + "expr": "success('build', 'build2')" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build5", + "name": "build5", + "if": { + "type": 3, + "expr": "success() && (github.foo == 'bar')" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build6", + "name": "build6", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build7", + "name": "build7", + "if": { + "type": 3, + "expr": "false || (always() && true)" + }, + "runs-on": "linux", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo Hello World" + } + ] + }, + { + "type": "job", + "id": "build8", + "name": "build8", + "if": { + "type": 3, + "expr": "success() && (false || (true && true))" + }, + "runs-on": "macos-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo Hello World" + } + ] + }, + { + "type": "job", + "id": "build9", + "name": "build9", + "if": { + "type": 3, + "expr": "false && success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + }, + { + "type": "job", + "id": "build10", + "name": "build10", + "if": { + "type": 3, + "expr": "success() && (toJSON( github.actor))" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo Hello World" + } + ] + }, + { + "type": "job", + "id": "build11", + "name": "build11", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "linux", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "always() && fromJSON(env.time) == 5" + }, + "run": "echo Hello World" + } + ] + }, + { + "type": "job", + "id": "build12", + "name": "build12", + "if": { + "type": 3, + "expr": "fromJson(toJSON(success()))" + }, + "runs-on": "linux", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo Hello World" + } + ] + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/job-needs.yml b/actions-workflow-parser/testdata/reader/job-needs.yml new file mode 100644 index 0000000..2d5e9fe --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-needs.yml @@ -0,0 +1,120 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + test-level-0: + needs: build # string + runs-on: ubuntu-latest + steps: + - run: echo hi + test-level-1: + needs: build + runs-on: ubuntu-latest + steps: + - run: echo hi + deploy: + needs: # sequence + - test-level-0 + - test-level-1 + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "test-level-0", + "name": "test-level-0", + "needs": [ + "build" + ], + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "test-level-1", + "name": "test-level-1", + "needs": [ + "build" + ], + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "deploy", + "name": "deploy", + "needs": [ + "test-level-0", + "test-level-1" + ], + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-outputs.yml b/actions-workflow-parser/testdata/reader/job-outputs.yml new file mode 100644 index 0000000..dcbd4d7 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-outputs.yml @@ -0,0 +1,48 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + one: + runs-on: ubuntu-latest + outputs: + job-out: ${{ steps.test.outputs.foo }} + steps: + - run: echo 1 + id: test +--- +{ + "jobs": [ + { + "type": "job", + "id": "one", + "name": "one", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "outputs": { + "type": 2, + "map": [ + { + "Key": "job-out", + "Value": { + "type": 3, + "expr": "steps.test.outputs.foo" + } + } + ] + }, + "steps": [ + { + "id": "test", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-permissions.yml b/actions-workflow-parser/testdata/reader/job-permissions.yml new file mode 100644 index 0000000..df3095c --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-permissions.yml @@ -0,0 +1,295 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +permissions: + checks: read +jobs: + # Defaults workflow-level permissions + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + + # Explicit job-level permissions + build2: + runs-on: ubuntu-latest + permissions: + actions: read + steps: + - run: echo hi + build3: + runs-on: ubuntu-latest + permissions: read-all + steps: + - run: echo hi + build4: + runs-on: ubuntu-latest + permissions: write-all + steps: + - run: echo hi + build5: + runs-on: ubuntu-latest + permissions: + actions: read + checks: read + contents: read + deployments: read + issues: read + discussions: read + packages: read + pages: read + pull-requests: read + security-events: read + statuses: read + repository-projects: read + steps: + - run: echo hi + build6: + runs-on: ubuntu-latest + permissions: + actions: write + checks: write + contents: write + deployments: write + issues: write + discussions: write + packages: write + pages: write + pull-requests: write + security-events: write + statuses: write + repository-projects: write + steps: + - run: echo hi + build7: + runs-on: ubuntu-latest + permissions: + actions: none + checks: none + contents: none + deployments: none + issues: none + discussions: none + packages: none + pages: none + pull-requests: none + security-events: none + statuses: none + repository-projects: none + steps: + - run: echo hi +--- +{ + "permissions": { + "checks": "read" + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "checks": "read" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "read" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build3", + "name": "build3", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "read", + "checks": "read", + "contents": "read", + "deployments": "read", + "discussions": "read", + "id-token": "read", + "issues": "read", + "packages": "read", + "pages": "read", + "pull-requests": "read", + "repository-projects": "read", + "security-events": "read", + "statuses": "read" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build4", + "name": "build4", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "write", + "checks": "write", + "contents": "write", + "deployments": "write", + "discussions": "write", + "id-token": "write", + "issues": "write", + "packages": "write", + "pages": "write", + "pull-requests": "write", + "repository-projects": "write", + "security-events": "write", + "statuses": "write" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build5", + "name": "build5", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "read", + "checks": "read", + "contents": "read", + "deployments": "read", + "discussions": "read", + "issues": "read", + "packages": "read", + "pages": "read", + "pull-requests": "read", + "repository-projects": "read", + "security-events": "read", + "statuses": "read" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build6", + "name": "build6", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "write", + "checks": "write", + "contents": "write", + "deployments": "write", + "discussions": "write", + "issues": "write", + "packages": "write", + "pages": "write", + "pull-requests": "write", + "repository-projects": "write", + "security-events": "write", + "statuses": "write" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build7", + "name": "build7", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": {}, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-runs-on.yml b/actions-workflow-parser/testdata/reader/job-runs-on.yml new file mode 100644 index 0000000..c774191 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-runs-on.yml @@ -0,0 +1,295 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest # string + steps: + - run: echo hi + build2: + runs-on: # sequence + - label 1 + - label 2 + steps: + - run: echo hi + build3: + runs-on: # mapping + group: my group + labels: + - label 1 + - label 2 + steps: + - run: echo hi + build4: + runs-on: # mapping, org prefix + group: org/my group + labels: + - label 1 + - label 2 + steps: + - run: echo hi + build5: + runs-on: # mapping, organization prefix + group: organization/my group + labels: + - label 1 + - label 2 + steps: + - run: echo hi + build6: + runs-on: # mapping, ent prefix + group: ent/my group + labels: + - label 1 + - label 2 + steps: + - run: echo hi + build7: + runs-on: # mapping, enterprise prefix + group: enterprise/my group + labels: + - label 1 + - label 2 + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 1, + "seq": [ + "label 1", + "label 2" + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build3", + "name": "build3", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": "my group" + }, + { + "Key": "labels", + "Value": { + "type": 1, + "seq": [ + "label 1", + "label 2" + ] + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build4", + "name": "build4", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": "org/my group" + }, + { + "Key": "labels", + "Value": { + "type": 1, + "seq": [ + "label 1", + "label 2" + ] + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build5", + "name": "build5", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": "organization/my group" + }, + { + "Key": "labels", + "Value": { + "type": 1, + "seq": [ + "label 1", + "label 2" + ] + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build6", + "name": "build6", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": "ent/my group" + }, + { + "Key": "labels", + "Value": { + "type": 1, + "seq": [ + "label 1", + "label 2" + ] + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build7", + "name": "build7", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": "enterprise/my group" + }, + { + "Key": "labels", + "Value": { + "type": 1, + "seq": [ + "label 1", + "label 2" + ] + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-services-args.yml b/actions-workflow-parser/testdata/reader/job-services-args.yml new file mode 100644 index 0000000..b28fd96 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-services-args.yml @@ -0,0 +1,193 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: linux + env: + super_secret: ${{ secrets.SuperSecret }} + services: + nginx: + image: nginx + ports: + - 8080:80 + env: + SERVER: production + credentials: + username: ${{ github.actor }} + password: ${{ secrets.github_token }} + volumes: + - my_docker_volume:/volume_mount + options: + --blkio-weight 10 + --add-host 100.00.1.11 + --cpus 3 + redis: + image: redis + ports: + - 6379/tcp + env: + FIRST_NAME: Mona + LAST_NAME: Cat + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + options: --cpu-rt-runtime 1000 + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "env": { + "type": 2, + "map": [ + { + "Key": "super_secret", + "Value": { + "type": 3, + "expr": "secrets.SuperSecret" + } + } + ] + }, + "runs-on": "linux", + "services": { + "type": 2, + "map": [ + { + "Key": "nginx", + "Value": { + "type": 2, + "map": [ + { + "Key": "image", + "Value": "nginx" + }, + { + "Key": "ports", + "Value": { + "type": 1, + "seq": [ + "8080:80" + ] + } + }, + { + "Key": "env", + "Value": { + "type": 2, + "map": [ + { + "Key": "SERVER", + "Value": "production" + } + ] + } + }, + { + "Key": "credentials", + "Value": { + "type": 2, + "map": [ + { + "Key": "username", + "Value": { + "type": 3, + "expr": "github.actor" + } + }, + { + "Key": "password", + "Value": { + "type": 3, + "expr": "secrets.github_token" + } + } + ] + } + }, + { + "Key": "volumes", + "Value": { + "type": 1, + "seq": [ + "my_docker_volume:/volume_mount" + ] + } + }, + { + "Key": "options", + "Value": "--blkio-weight 10 --add-host 100.00.1.11 --cpus 3" + } + ] + } + }, + { + "Key": "redis", + "Value": { + "type": 2, + "map": [ + { + "Key": "image", + "Value": "redis" + }, + { + "Key": "ports", + "Value": { + "type": 1, + "seq": [ + "6379/tcp" + ] + } + }, + { + "Key": "env", + "Value": { + "type": 2, + "map": [ + { + "Key": "FIRST_NAME", + "Value": "Mona" + }, + { + "Key": "LAST_NAME", + "Value": "Cat" + }, + { + "Key": "GITHUB_TOKEN", + "Value": { + "type": 3, + "expr": "secrets.GITHUB_TOKEN" + } + } + ] + } + }, + { + "Key": "options", + "Value": "--cpu-rt-runtime 1000" + } + ] + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/job-services.yml b/actions-workflow-parser/testdata/reader/job-services.yml new file mode 100644 index 0000000..727e32f --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-services.yml @@ -0,0 +1,121 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: linux + services: + nginx: nginx + redis: redis:latest + postgres: + image: postgres:latest + ports: + - 5432 + volumes: + - /dbdata:/data + service_from_gpr: + image: docker.pkg.github.com + credentials: + username: username + password: ${{ github.token }} + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "linux", + "services": { + "type": 2, + "map": [ + { + "Key": "nginx", + "Value": "nginx" + }, + { + "Key": "redis", + "Value": "redis:latest" + }, + { + "Key": "postgres", + "Value": { + "type": 2, + "map": [ + { + "Key": "image", + "Value": "postgres:latest" + }, + { + "Key": "ports", + "Value": { + "type": 1, + "seq": [ + "5432" + ] + } + }, + { + "Key": "volumes", + "Value": { + "type": 1, + "seq": [ + "/dbdata:/data" + ] + } + } + ] + } + }, + { + "Key": "service_from_gpr", + "Value": { + "type": 2, + "map": [ + { + "Key": "image", + "Value": "docker.pkg.github.com" + }, + { + "Key": "credentials", + "Value": { + "type": 2, + "map": [ + { + "Key": "username", + "Value": "username" + }, + { + "Key": "password", + "Value": { + "type": 3, + "expr": "github.token" + } + } + ] + } + } + ] + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-strategy.yml b/actions-workflow-parser/testdata/reader/job-strategy.yml new file mode 100644 index 0000000..3e8551e --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-strategy.yml @@ -0,0 +1,211 @@ +include-source: false # Drop file/line/col from output +skip: + - Go +--- +on: push +jobs: + build: + strategy: + matrix: + node-version: [4, 6, 8, 10] + npm-version: [1, 2, 3] + include: + - node-version: 4 + npm: 2 + exclude: + - node-version: 4 + npm-version: 2 + runs-on: ubuntu-latest + steps: + - run: echo hi + build2: + strategy: + matrix: + sha: + - ${{ github.event.after }} + - ${{ github.event.before }} + include: + - sha: ${{ github.event.after }} + is-after: true + runs-on: ubuntu-latest + steps: + - run: echo ${{ matrix.sha }} +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "node-version", + "Value": { + "type": 1, + "seq": [ + 4, + 6, + 8, + 10 + ] + } + }, + { + "Key": "npm-version", + "Value": { + "type": 1, + "seq": [ + 1, + 2, + 3 + ] + } + }, + { + "Key": "include", + "Value": { + "type": 1, + "seq": [ + { + "type": 2, + "map": [ + { + "Key": "node-version", + "Value": 4 + }, + { + "Key": "npm", + "Value": 2 + } + ] + } + ] + } + }, + { + "Key": "exclude", + "Value": { + "type": 1, + "seq": [ + { + "type": 2, + "map": [ + { + "Key": "node-version", + "Value": 4 + }, + { + "Key": "npm-version", + "Value": 2 + } + ] + } + ] + } + } + ] + } + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "sha", + "Value": { + "type": 1, + "seq": [ + { + "type": 3, + "expr": "github.event.after" + }, + { + "type": 3, + "expr": "github.event.before" + } + ] + } + }, + { + "Key": "include", + "Value": { + "type": 1, + "seq": [ + { + "type": 2, + "map": [ + { + "Key": "sha", + "Value": { + "type": 3, + "expr": "github.event.after" + } + }, + { + "Key": "is-after", + "Value": true + } + ] + } + ] + } + } + ] + } + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": { + "type": 3, + "expr": "format('echo {0}', matrix.sha)" + } + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-timeout-minutes.yml b/actions-workflow-parser/testdata/reader/job-timeout-minutes.yml new file mode 100644 index 0000000..f6c7b76 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-timeout-minutes.yml @@ -0,0 +1,124 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 123 + steps: + - run: echo hi + build2: + strategy: + matrix: + include: + - runs-on: ubuntu-latest + timeout-minutes: 5 + - runs-on: windows-latest + timeout-minutes: 6 + runs-on: ${{ matrix.runs-on }} + timeout-minutes: ${{ matrix.timeout-minutes }} + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": 123, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "build2", + "name": "build2", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "include", + "Value": { + "type": 1, + "seq": [ + { + "type": 2, + "map": [ + { + "Key": "runs-on", + "Value": "ubuntu-latest" + }, + { + "Key": "timeout-minutes", + "Value": 5 + } + ] + }, + { + "type": 2, + "map": [ + { + "Key": "runs-on", + "Value": "windows-latest" + }, + { + "Key": "timeout-minutes", + "Value": 6 + } + ] + } + ] + } + } + ] + } + } + ] + }, + "timeout-minutes": { + "type": 3, + "expr": "matrix.timeout-minutes" + }, + "runs-on": { + "type": 3, + "expr": "matrix.runs-on" + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/job-with-outputs-context.yml b/actions-workflow-parser/testdata/reader/job-with-outputs-context.yml new file mode 100644 index 0000000..ac88dc3 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/job-with-outputs-context.yml @@ -0,0 +1,48 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.a }} + steps: + - id: a + run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "outputs": { + "type": 2, + "map": [ + { + "Key": "environment", + "Value": { + "type": 3, + "expr": "steps.a" + } + } + ] + }, + "steps": [ + { + "id": "a", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/misc-jobs.yml b/actions-workflow-parser/testdata/reader/misc-jobs.yml new file mode 100644 index 0000000..ca88646 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/misc-jobs.yml @@ -0,0 +1,253 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.experimental }} + name: ${{ github.actor }} + timeout-minutes: ${{ github.ref }} + cancel-timeout-minutes: 300 + concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + environment: + url: ${{ steps.step_id.outputs.url_output }} + name: production_environment + outputs: + output1: ${{ steps.step1.outputs.test }} + output2: ${{ steps.step2.outputs.test }} + defaults: + run: + shell: bash + working-directory: scripts + build2: + runs-on: [self-hosted, linux] + continue-on-error: true + name: Jobs Repro Hardcode + timeout-minutes: 360 + cancel-timeout-minutes: 300 + concurrency: + group: groupA + cancel-in-progress: true + env: + GITHUB_TOKEN: secret_token + environment: + name: environment-${{ github.event.number }} + url: https://github.com + outputs: + output1: Hello + output2: World + defaults: + run: + shell: bash + working-directory: scripts +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": { + "type": 3, + "expr": "github.actor" + }, + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": { + "type": 3, + "expr": "matrix.experimental" + }, + "timeout-minutes": { + "type": 3, + "expr": "github.ref" + }, + "cancel-timeout-minutes": 300, + "concurrency": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": { + "type": 3, + "expr": "github.ref" + } + }, + { + "Key": "cancel-in-progress", + "Value": true + } + ] + }, + "env": { + "type": 2, + "map": [ + { + "Key": "GITHUB_TOKEN", + "Value": { + "type": 3, + "expr": "secrets.GITHUB_TOKEN" + } + } + ] + }, + "environment": { + "type": 2, + "map": [ + { + "Key": "url", + "Value": { + "type": 3, + "expr": "steps.step_id.outputs.url_output" + } + }, + { + "Key": "name", + "Value": "production_environment" + } + ] + }, + "defaults": { + "type": 2, + "map": [ + { + "Key": "run", + "Value": { + "type": 2, + "map": [ + { + "Key": "shell", + "Value": "bash" + }, + { + "Key": "working-directory", + "Value": "scripts" + } + ] + } + } + ] + }, + "runs-on": { + "type": 3, + "expr": "matrix.os" + }, + "outputs": { + "type": 2, + "map": [ + { + "Key": "output1", + "Value": { + "type": 3, + "expr": "steps.step1.outputs.test" + } + }, + { + "Key": "output2", + "Value": { + "type": 3, + "expr": "steps.step2.outputs.test" + } + } + ] + } + }, + { + "type": "job", + "id": "build2", + "name": "Jobs Repro Hardcode", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "timeout-minutes": 360, + "cancel-timeout-minutes": 300, + "concurrency": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": "groupA" + }, + { + "Key": "cancel-in-progress", + "Value": true + } + ] + }, + "env": { + "type": 2, + "map": [ + { + "Key": "GITHUB_TOKEN", + "Value": "secret_token" + } + ] + }, + "environment": { + "type": 2, + "map": [ + { + "Key": "name", + "Value": { + "type": 3, + "expr": "format('environment-{0}', github.event.number)" + } + }, + { + "Key": "url", + "Value": "https://github.com" + } + ] + }, + "defaults": { + "type": 2, + "map": [ + { + "Key": "run", + "Value": { + "type": 2, + "map": [ + { + "Key": "shell", + "Value": "bash" + }, + { + "Key": "working-directory", + "Value": "scripts" + } + ] + } + } + ] + }, + "runs-on": { + "type": 1, + "seq": [ + "self-hosted", + "linux" + ] + }, + "outputs": { + "type": 2, + "map": [ + { + "Key": "output1", + "Value": "Hello" + }, + { + "Key": "output2", + "Value": "World" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/misc-steps.yml b/actions-workflow-parser/testdata/reader/misc-steps.yml new file mode 100644 index 0000000..b75efd8 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/misc-steps.yml @@ -0,0 +1,97 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Repro + uses: actions/checkout@v3 + with: + first_name: Mona + middle_name: The + last_name: Octocat + entrypoint: /bin/echo + args: The ${{ github.event_name }} event triggered this step. + - run: echo hi + shell: bash + working-directory: scripts + timeout-minutes: 360 + env: + GITHUB_TOKEN: secret_token +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "name": "Repro", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3", + "with": { + "type": 2, + "map": [ + { + "Key": "first_name", + "Value": "Mona" + }, + { + "Key": "middle_name", + "Value": "The" + }, + { + "Key": "last_name", + "Value": "Octocat" + }, + { + "Key": "entrypoint", + "Value": "/bin/echo" + }, + { + "Key": "args", + "Value": { + "type": 3, + "expr": "format('The {0} event triggered this step.', github.event_name)" + } + } + ] + } + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": 360, + "env": { + "type": 2, + "map": [ + { + "Key": "GITHUB_TOKEN", + "Value": "secret_token" + } + ] + }, + "working-directory": "scripts", + "shell": "bash", + "run": "echo hi" + } + ] + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/mvp.yml b/actions-workflow-parser/testdata/reader/mvp.yml new file mode 100644 index 0000000..417ef36 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/mvp.yml @@ -0,0 +1,88 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the "main" branch + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: [ self-hosted ] + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v3 + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! + + # Runs a set of commands using the runners shell + - name: Run a multi-line script + run: | + echo Add other actions to build, + echo test, and deploy your project. +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 1, + "seq": [ + "self-hosted" + ] + }, + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "name": "Run a one-line script", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo Hello, world!" + }, + { + "id": "__run_2", + "name": "Run a multi-line script", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo Add other actions to build,\necho test, and deploy your project." + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/on-workflow_call-mapping.yml b/actions-workflow-parser/testdata/reader/on-workflow_call-mapping.yml new file mode 100644 index 0000000..9d1770d --- /dev/null +++ b/actions-workflow-parser/testdata/reader/on-workflow_call-mapping.yml @@ -0,0 +1,51 @@ +include-source: false # Drop file/line/col from output +--- +on: + push: + branches: + - main + workflow_call: + inputs: + test_string: + description: test input + required: true + type: string + test_number: + required: false + type: number + test_boolean: + required: false + type: boolean + secrets: + shh: + required: true +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/on-workflow_call-sequence.yml b/actions-workflow-parser/testdata/reader/on-workflow_call-sequence.yml new file mode 100644 index 0000000..2e213a2 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/on-workflow_call-sequence.yml @@ -0,0 +1,35 @@ +include-source: false # Drop file/line/col from output +--- +on: + - workflow_call + - push +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/on-workflow_call.yml b/actions-workflow-parser/testdata/reader/on-workflow_call.yml new file mode 100644 index 0000000..9d88623 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/on-workflow_call.yml @@ -0,0 +1,33 @@ +include-source: false # Drop file/line/col from output +--- +on: workflow_call +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs-null.yml b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs-null.yml new file mode 100644 index 0000000..c1a963d --- /dev/null +++ b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs-null.yml @@ -0,0 +1,37 @@ +include-source: false # Drop file/line/col from output +skip: + - Typescript +--- +on: + workflow_dispatch: + inputs: +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs.yml b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs.yml new file mode 100644 index 0000000..56920d2 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs.yml @@ -0,0 +1,60 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - Typescript +--- +on: + workflow_dispatch: + inputs: + input1: + default: value + type: string + input2: + type: boolean + input3: + description: Log level + required: true + default: warning + type: choice + options: + - info + - warning + - debug + # Defaults to string + input4: +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "input-types": { + "input1": "string", + "input2": "boolean", + "input3": "choice", + "input4": "string" + }, + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/on-workflow_dispatch-mapping.yml b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-mapping.yml new file mode 100644 index 0000000..59a2110 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-mapping.yml @@ -0,0 +1,34 @@ +include-source: false # Drop file/line/col from output +--- +on: + workflow_dispatch: {} +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/on-workflow_dispatch-null.yml b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-null.yml new file mode 100644 index 0000000..6fc23a8 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-null.yml @@ -0,0 +1,34 @@ +include-source: false # Drop file/line/col from output +--- +on: + workflow_dispatch: +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/on-workflow_dispatch-string.yml b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-string.yml new file mode 100644 index 0000000..c8fca54 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-string.yml @@ -0,0 +1,33 @@ +include-source: false # Drop file/line/col from output +--- +on: workflow_dispatch +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/on-workflow_dispatch-unknown-keys-ignored.yml b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-unknown-keys-ignored.yml new file mode 100644 index 0000000..2b53084 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-unknown-keys-ignored.yml @@ -0,0 +1,37 @@ +include-source: false # Drop file/line/col from output +skip: + - Typescript +--- +on: + workflow_dispatch: + unknown-key: asdf +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/permissions.yml b/actions-workflow-parser/testdata/reader/permissions.yml new file mode 100644 index 0000000..54ba0de --- /dev/null +++ b/actions-workflow-parser/testdata/reader/permissions.yml @@ -0,0 +1,152 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +permissions: + actions: read + checks: read + contents: read + discussions: read + deployments: read + id-token: none + issues: read + packages: read + pages: read + pull-requests: read + repository-projects: read + security-events: read + statuses: read +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true + deploy: + runs-on: ubuntu-latest + permissions: + actions: write + checks: write + contents: write + deployments: write + id-token: write + issues: write + discussions: write + packages: write + pages: write + pull-requests: write + repository-projects: write + security-events: write + statuses: write + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "permissions": { + "actions": "read", + "checks": "read", + "contents": "read", + "deployments": "read", + "discussions": "read", + "issues": "read", + "packages": "read", + "pages": "read", + "pull-requests": "read", + "repository-projects": "read", + "security-events": "read", + "statuses": "read" + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "read", + "checks": "read", + "contents": "read", + "deployments": "read", + "discussions": "read", + "issues": "read", + "packages": "read", + "pages": "read", + "pull-requests": "read", + "repository-projects": "read", + "security-events": "read", + "statuses": "read" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "write", + "checks": "write", + "contents": "write", + "deployments": "write", + "discussions": "write", + "id-token": "write", + "issues": "write", + "packages": "write", + "pages": "write", + "pull-requests": "write", + "repository-projects": "write", + "security-events": "write", + "statuses": "write" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/preserves-source-info-basic.yml b/actions-workflow-parser/testdata/reader/preserves-source-info-basic.yml new file mode 100644 index 0000000..9063438 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/preserves-source-info-basic.yml @@ -0,0 +1,181 @@ +include-source: true # Preserve file/line/col in serialized output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: true + max-parallel: 1 + matrix: + cfg: + - null # null + - true # bool + - 1 # number + - abc # string + - ${{ github.sha }} # expression + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": { + "type": 0, + "file": 1, + "line": 3, + "col": 3, + "lit": "build" + }, + "name": { + "type": 0, + "file": 1, + "line": 3, + "col": 3, + "lit": "build" + }, + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "file": 1, + "line": 6, + "col": 7, + "map": [ + { + "Key": { + "type": 0, + "file": 1, + "line": 6, + "col": 7, + "lit": "fail-fast" + }, + "Value": { + "type": 5, + "file": 1, + "line": 6, + "col": 18, + "bool": true + } + }, + { + "Key": { + "type": 0, + "file": 1, + "line": 7, + "col": 7, + "lit": "max-parallel" + }, + "Value": { + "type": 6, + "file": 1, + "line": 7, + "col": 21, + "num": 1 + } + }, + { + "Key": { + "type": 0, + "file": 1, + "line": 8, + "col": 7, + "lit": "matrix" + }, + "Value": { + "type": 2, + "file": 1, + "line": 9, + "col": 9, + "map": [ + { + "Key": { + "type": 0, + "file": 1, + "line": 9, + "col": 9, + "lit": "cfg" + }, + "Value": { + "type": 1, + "file": 1, + "line": 10, + "col": 11, + "seq": [ + { + "type": 7, + "file": 1, + "line": 10, + "col": 13 + }, + { + "type": 5, + "file": 1, + "line": 11, + "col": 13, + "bool": true + }, + { + "type": 6, + "file": 1, + "line": 12, + "col": 13, + "num": 1 + }, + { + "type": 0, + "file": 1, + "line": 13, + "col": 13, + "lit": "abc" + }, + { + "type": 3, + "file": 1, + "line": 14, + "col": 13, + "expr": "github.sha" + } + ] + } + } + ] + } + } + ] + }, + "runs-on": { + "type": 0, + "file": 1, + "line": 4, + "col": 14, + "lit": "ubuntu-latest" + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": { + "type": 0, + "file": 1, + "line": 16, + "col": 14, + "lit": "echo hi" + } + } + ] + } + ], + "file-table": [ + ".github/workflows/preserves-source-info-basic.yml" + ] +} diff --git a/actions-workflow-parser/testdata/reader/preserves-source-info-reusable-workflow.yml b/actions-workflow-parser/testdata/reader/preserves-source-info-reusable-workflow.yml new file mode 100644 index 0000000..f7ed89e --- /dev/null +++ b/actions-workflow-parser/testdata/reader/preserves-source-info-reusable-workflow.yml @@ -0,0 +1,215 @@ +include-source: true # Preserve file/line/col in serialized output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + uses: some-org-1/some-repo-1/.github/workflows/build.yml@v1 + deploy: + uses: some-org-2/some-repo-2/.github/workflows/deploy.yml@v2 +--- +some-org-1/some-repo-1/.github/workflows/build.yml@v1 +--- +on: + workflow_call: +jobs: + build-nested: + runs-on: ubuntu-latest + steps: + - run: ./build +--- +some-org-2/some-repo-2/.github/workflows/deploy.yml@v2 +--- +on: + workflow_call: +jobs: + deploy-nested: + runs-on: ubuntu-latest + steps: + - run: ./deploy +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": { + "type": 0, + "file": 1, + "line": 3, + "col": 3, + "lit": "build" + }, + "Name": { + "type": 0, + "file": 1, + "line": 3, + "col": 3, + "lit": "build" + }, + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": { + "type": 0, + "file": 1, + "line": 4, + "col": 11, + "lit": "some-org-1/some-repo-1/.github/workflows/build.yml@v1" + }, + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": { + "type": 0, + "file": 2, + "line": 4, + "col": 3, + "lit": "build-nested" + }, + "name": { + "type": 0, + "file": 2, + "line": 4, + "col": 3, + "lit": "build-nested" + }, + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 0, + "file": 2, + "line": 5, + "col": 14, + "lit": "ubuntu-latest" + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": { + "type": 0, + "file": 2, + "line": 7, + "col": 14, + "lit": "./build" + } + } + ] + } + ] + }, + { + "Type": "reusableWorkflowJob", + "Id": { + "type": 0, + "file": 1, + "line": 5, + "col": 3, + "lit": "deploy" + }, + "Name": { + "type": 0, + "file": 1, + "line": 5, + "col": 3, + "lit": "deploy" + }, + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": { + "type": 0, + "file": 1, + "line": 6, + "col": 11, + "lit": "some-org-2/some-repo-2/.github/workflows/deploy.yml@v2" + }, + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": { + "type": 0, + "file": 3, + "line": 4, + "col": 3, + "lit": "deploy-nested" + }, + "name": { + "type": 0, + "file": 3, + "line": 4, + "col": 3, + "lit": "deploy-nested" + }, + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 0, + "file": 3, + "line": 5, + "col": 14, + "lit": "ubuntu-latest" + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": { + "type": 0, + "file": 3, + "line": 7, + "col": 14, + "lit": "./deploy" + } + } + ] + } + ] + } + ], + "file-table": [ + ".github/workflows/preserves-source-info-reusable-workflow.yml", + "some-org-1/some-repo-1/.github/workflows/build.yml@v1", + "some-org-2/some-repo-2/.github/workflows/deploy.yml@v2" + ] +} diff --git a/actions-workflow-parser/testdata/reader/preserves-source-info-simple.yml b/actions-workflow-parser/testdata/reader/preserves-source-info-simple.yml new file mode 100644 index 0000000..e7797df --- /dev/null +++ b/actions-workflow-parser/testdata/reader/preserves-source-info-simple.yml @@ -0,0 +1,126 @@ +include-source: true # Preserve file/line/col in serialized output +skip: + - TypeScript +--- +# This is meant to cover all the different types of TemplateToken that are output +# with include-source: true. Currently it is missing type 4 (insert expression) +# and type 7 (null), once we have matrix strategy we should be able to get null or combine +# this test with preserves-source-info-basic.yml. +on: push +jobs: + build: # string, type 0 + concurrency: # map, type 2 + group: ${{ github.ref }} # basic expression, type 3 + cancel-in-progress: true # boolean, type 5 + runs-on: # sequence, type 1 + - ubuntu-latest + steps: + - run: echo hi + timeout-minutes: 360 # number, type 6 +--- +{ + "jobs": [ + { + "type": "job", + "id": { + "type": 0, + "file": 1, + "line": 7, + "col": 3, + "lit": "build" + }, + "name": { + "type": 0, + "file": 1, + "line": 7, + "col": 3, + "lit": "build" + }, + "if": { + "type": 3, + "expr": "success()" + }, + "concurrency": { + "type": 2, + "file": 1, + "line": 9, + "col": 7, + "map": [ + { + "Key": { + "type": 0, + "file": 1, + "line": 9, + "col": 7, + "lit": "group" + }, + "Value": { + "type": 3, + "file": 1, + "line": 9, + "col": 14, + "expr": "github.ref" + } + }, + { + "Key": { + "type": 0, + "file": 1, + "line": 10, + "col": 7, + "lit": "cancel-in-progress" + }, + "Value": { + "type": 5, + "file": 1, + "line": 10, + "col": 27, + "bool": true + } + } + ] + }, + "runs-on": { + "type": 1, + "file": 1, + "line": 12, + "col": 7, + "seq": [ + { + "type": 0, + "file": 1, + "line": 12, + "col": 9, + "lit": "ubuntu-latest" + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": { + "type": 6, + "file": 1, + "line": 15, + "col": 26, + "num": 360 + }, + "run": { + "type": 0, + "file": 1, + "line": 14, + "col": 14, + "lit": "echo hi" + } + } + ] + } + ], + "file-table": [ + ".github/workflows/preserves-source-info-simple.yml" + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-inputs.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-inputs.yml new file mode 100644 index 0000000..0e209c6 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-inputs.yml @@ -0,0 +1,265 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + uses: contoso/templates/.github/workflows/build.yml@v1 + with: + some-boolean-1: true + some-boolean-2: false + some-number-1: 123 + some-number-2: 456 + some-string-1: abc + some-string-2: def +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: + workflow_call: + inputs: + some-boolean-1: + type: boolean + some-boolean-2: + required: true + type: boolean + some-boolean-3: + type: boolean + default: true + some-number-1: + type: number + some-number-2: + required: true + type: number + some-number-3: + type: number + default: 789 + some-string-1: + type: string + some-string-2: + required: true + type: string + some-string-3: + type: string + default: ghi +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": { + "type": 2, + "map": [ + { + "Key": "some-boolean-1", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "boolean" + } + ] + } + }, + { + "Key": "some-boolean-2", + "Value": { + "type": 2, + "map": [ + { + "Key": "required", + "Value": true + }, + { + "Key": "type", + "Value": "boolean" + } + ] + } + }, + { + "Key": "some-boolean-3", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "boolean" + }, + { + "Key": "default", + "Value": true + } + ] + } + }, + { + "Key": "some-number-1", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "number" + } + ] + } + }, + { + "Key": "some-number-2", + "Value": { + "type": 2, + "map": [ + { + "Key": "required", + "Value": true + }, + { + "Key": "type", + "Value": "number" + } + ] + } + }, + { + "Key": "some-number-3", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "number" + }, + { + "Key": "default", + "Value": 789 + } + ] + } + }, + { + "Key": "some-string-1", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "string" + } + ] + } + }, + { + "Key": "some-string-2", + "Value": { + "type": 2, + "map": [ + { + "Key": "required", + "Value": true + }, + { + "Key": "type", + "Value": "string" + } + ] + } + }, + { + "Key": "some-string-3", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "string" + }, + { + "Key": "default", + "Value": "ghi" + } + ] + } + } + ] + }, + "InputValues": { + "type": 2, + "map": [ + { + "Key": "some-boolean-1", + "Value": true + }, + { + "Key": "some-boolean-2", + "Value": false + }, + { + "Key": "some-number-1", + "Value": 123 + }, + { + "Key": "some-number-2", + "Value": 456 + }, + { + "Key": "some-string-1", + "Value": "abc" + }, + { + "Key": "some-string-2", + "Value": "def" + } + ] + }, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-basic.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-basic.yml new file mode 100644 index 0000000..b4721ab --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-basic.yml @@ -0,0 +1,294 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi + deploy-1: + needs: build + uses: contoso/templates/.github/workflows/deploy.yml@v1 + with: + app_name: my app + secrets: # mapping + shh: ${{ secrets.my_secret }} + deploy-2: + needs: build + uses: contoso/templates/.github/workflows/deploy.yml@v1 + with: + app_name: my app + secrets: inherit # inherit +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: + workflow_call: + inputs: + app_name: + required: true + type: string + secrets: + shh: # required + required: true + shh2: # null +jobs: + job1: + runs-on: ubuntu-latest + outputs: + output1: ${{ steps.step1.outputs.test }} + steps: + - run: echo \""::set-output name=test::hello\"" +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "Type": "reusableWorkflowJob", + "Id": "deploy-1", + "Name": "deploy-1", + "Needs": [ + "build" + ], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/deploy.yml@v1", + "Permissions": null, + "InputDefinitions": { + "type": 2, + "map": [ + { + "Key": "app_name", + "Value": { + "type": 2, + "map": [ + { + "Key": "required", + "Value": true + }, + { + "Key": "type", + "Value": "string" + } + ] + } + } + ] + }, + "InputValues": { + "type": 2, + "map": [ + { + "Key": "app_name", + "Value": "my app" + } + ] + }, + "SecretDefinitions": { + "type": 2, + "map": [ + { + "Key": "shh", + "Value": { + "type": 2, + "map": [ + { + "Key": "required", + "Value": true + } + ] + } + }, + { + "Key": "shh2", + "Value": null + } + ] + }, + "SecretValues": { + "type": 2, + "map": [ + { + "Key": "shh", + "Value": { + "type": 3, + "expr": "secrets.my_secret" + } + } + ] + }, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "job1", + "name": "job1", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "outputs": { + "type": 2, + "map": [ + { + "Key": "output1", + "Value": { + "type": 3, + "expr": "steps.step1.outputs.test" + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo \\\"\"::set-output name=test::hello\\\"\"" + } + ] + } + ] + }, + { + "Type": "reusableWorkflowJob", + "Id": "deploy-2", + "Name": "deploy-2", + "Needs": [ + "build" + ], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/deploy.yml@v1", + "Permissions": null, + "InputDefinitions": { + "type": 2, + "map": [ + { + "Key": "app_name", + "Value": { + "type": 2, + "map": [ + { + "Key": "required", + "Value": true + }, + { + "Key": "type", + "Value": "string" + } + ] + } + } + ] + }, + "InputValues": { + "type": 2, + "map": [ + { + "Key": "app_name", + "Value": "my app" + } + ] + }, + "SecretDefinitions": { + "type": 2, + "map": [ + { + "Key": "shh", + "Value": { + "type": 2, + "map": [ + { + "Key": "required", + "Value": true + } + ] + } + }, + { + "Key": "shh2", + "Value": null + } + ] + }, + "SecretValues": null, + "InheritSecrets": true, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "job1", + "name": "job1", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "outputs": { + "type": 2, + "map": [ + { + "Key": "output1", + "Value": { + "type": 3, + "expr": "steps.step1.outputs.test" + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo \\\"\"::set-output name=test::hello\\\"\"" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-inputs-type-coercion.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-inputs-type-coercion.yml new file mode 100644 index 0000000..31c89cb --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-inputs-type-coercion.yml @@ -0,0 +1,155 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + deploy: + uses: contoso/templates/.github/workflows/deploy.yml@v1 + with: + some-string-1: null + some-string-2: true + some-string-3: false + some-string-4: 456 +--- +contoso/templates/.github/workflows/deploy.yml@v1 +--- +on: + workflow_call: + inputs: + some-string-1: + type: string + some-string-2: + type: string + some-string-3: + type: string + some-string-4: + type: string +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "deploy", + "Name": "deploy", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/deploy.yml@v1", + "Permissions": null, + "InputDefinitions": { + "type": 2, + "map": [ + { + "Key": "some-string-1", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "string" + } + ] + } + }, + { + "Key": "some-string-2", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "string" + } + ] + } + }, + { + "Key": "some-string-3", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "string" + } + ] + } + }, + { + "Key": "some-string-4", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "string" + } + ] + } + } + ] + }, + "InputValues": { + "type": 2, + "map": [ + { + "Key": "some-string-1", + "Value": "" + }, + { + "Key": "some-string-2", + "Value": true + }, + { + "Key": "some-string-3", + "Value": false + }, + { + "Key": "some-string-4", + "Value": 456 + } + ] + }, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "job1", + "name": "job1", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml new file mode 100644 index 0000000..5b52215 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml @@ -0,0 +1,167 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + uses: contoso/templates/.github/workflows/build.yml@v1 + build1: + name: custom name + uses: contoso/templates/.github/workflows/build.yml@v1 + build2: + name: ${{ github.ref }} + uses: contoso/templates/.github/workflows/build.yml@v1 +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: workflow_call +jobs: + build-it: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "build-it", + "name": "build-it", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] + }, + { + "Type": "reusableWorkflowJob", + "Id": "build1", + "Name": "custom name", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "build-it", + "name": "build-it", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] + }, + { + "Type": "reusableWorkflowJob", + "Id": "build2", + "Name": { + "type": 3, + "expr": "github.ref" + }, + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "build-it", + "name": "build-it", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-basic.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-basic.yml new file mode 100644 index 0000000..9ae18a1 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-basic.yml @@ -0,0 +1,101 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +max-nested-reusable-workflows-depth: 2 +--- +on: push +jobs: + deploy-level-0: + uses: contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-1: + uses: contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-1: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "deploy-level-0", + "Name": "deploy-level-0", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/deploy-level-1.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "deploy-level-1", + "Name": "deploy-level-1", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/deploy-level-2.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy-level-1", + "name": "deploy-level-1", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-permissions.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-permissions.yml new file mode 100644 index 0000000..f8b78fd --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-permissions.yml @@ -0,0 +1,149 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +max-nested-reusable-workflows-depth: 3 +--- +on: push +permissions: + actions: write +jobs: + deploy-level-0: + uses: contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-1.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-1: + uses: contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-2.yml@v1 +--- +on: workflow_call +permissions: + actions: read +jobs: + deploy-level-3: + uses: contoso/templates/.github/workflows/deploy-level-3.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-3.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-3: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "permissions": { + "actions": "write" + }, + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "deploy-level-0", + "Name": "deploy-level-0", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/deploy-level-1.yml@v1", + "Permissions": { + "actions": "write" + }, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "deploy-level-1", + "Name": "deploy-level-1", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/deploy-level-2.yml@v1", + "Permissions": { + "actions": "read" + }, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "deploy-level-3", + "Name": "deploy-level-3", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/deploy-level-3.yml@v1", + "Permissions": { + "actions": "read" + }, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy-level-3", + "name": "deploy-level-3", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "read" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-embedded-permissions-after-jobs.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-embedded-permissions-after-jobs.yml new file mode 100644 index 0000000..58a2555 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-embedded-permissions-after-jobs.yml @@ -0,0 +1,123 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +permissions: write-all +jobs: + build: + uses: contoso/templates/.github/workflows/build.yml@v1 +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: workflow_call +jobs: + # Inherits workflow level + deploy: + runs-on: ubuntu-latest + steps: + - run: echo 1 + # Overrides workflow level + deploy2: + permissions: + actions: read + runs-on: ubuntu-latest + steps: + - run: echo 1 +permissions: + actions: write +--- +{ + "permissions": { + "actions": "write", + "checks": "write", + "contents": "write", + "deployments": "write", + "discussions": "write", + "id-token": "write", + "issues": "write", + "packages": "write", + "pages": "write", + "pull-requests": "write", + "repository-projects": "write", + "security-events": "write", + "statuses": "write" + }, + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": { + "actions": "write" + }, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "write" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + }, + { + "type": "job", + "id": "deploy2", + "name": "deploy2", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "read" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-embedded-permissions.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-embedded-permissions.yml new file mode 100644 index 0000000..b0d0b14 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-embedded-permissions.yml @@ -0,0 +1,123 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +permissions: write-all +jobs: + build: + uses: contoso/templates/.github/workflows/build.yml@v1 +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: workflow_call +permissions: + actions: read +jobs: + # Inherits workflow level + deploy: + runs-on: ubuntu-latest + steps: + - run: echo 1 + # Overrides workflow level + deploy2: + permissions: + actions: write + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +{ + "permissions": { + "actions": "write", + "checks": "write", + "contents": "write", + "deployments": "write", + "discussions": "write", + "id-token": "write", + "issues": "write", + "packages": "write", + "pages": "write", + "pull-requests": "write", + "repository-projects": "write", + "security-events": "write", + "statuses": "write" + }, + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": { + "actions": "read" + }, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "read" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + }, + { + "type": "job", + "id": "deploy2", + "name": "deploy2", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "write" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-overrides-default-limited-read.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-overrides-default-limited-read.yml new file mode 100644 index 0000000..4eaca32 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-overrides-default-limited-read.yml @@ -0,0 +1,125 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +jobs: + # Inherit from default permissions policy + build: + uses: contoso/templates/.github/workflows/build.yml@v1 + # Override + build2: + permissions: + actions: write + uses: contoso/templates/.github/workflows/build.yml@v1 +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: workflow_call +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + }, + { + "Type": "reusableWorkflowJob", + "Id": "build2", + "Name": "build2", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": { + "actions": "write" + }, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "write" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-overrides-default-limited-write.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-overrides-default-limited-write.yml new file mode 100644 index 0000000..b9a0e5d --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-overrides-default-limited-write.yml @@ -0,0 +1,125 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: Write +--- +on: push +jobs: + # Inherit from default permissions policy + build: + uses: contoso/templates/.github/workflows/build.yml@v1 + # Override + build2: + permissions: + actions: write + uses: contoso/templates/.github/workflows/build.yml@v1 +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: workflow_call +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + }, + { + "Type": "reusableWorkflowJob", + "Id": "build2", + "Name": "build2", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": { + "actions": "write" + }, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "write" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-overrides-workflow-level.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-overrides-workflow-level.yml new file mode 100644 index 0000000..6833b2f --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-permissions-overrides-workflow-level.yml @@ -0,0 +1,135 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +permissions-policy: LimitedRead +--- +on: push +permissions: + actions: read +jobs: + # Inherit from workflow-level + build: + uses: contoso/templates/.github/workflows/build.yml@v1 + # Override + build2: + permissions: + actions: write + uses: contoso/templates/.github/workflows/build.yml@v1 +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: workflow_call +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +{ + "permissions": { + "actions": "read" + }, + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": { + "actions": "read" + }, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "read" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + }, + { + "Type": "reusableWorkflowJob", + "Id": "build2", + "Name": "build2", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": { + "actions": "write" + }, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "permissions": { + "actions": "write" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-strategy.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-strategy.yml new file mode 100644 index 0000000..adbe218 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-strategy.yml @@ -0,0 +1,131 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + strategy: + matrix: + version: + - v1 + - v2 + uses: contoso/templates/.github/workflows/build.yml@v1 + with: + version: ${{ matrix.version }} +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: + workflow_call: + inputs: + version: + type: string +jobs: + build-it: + runs-on: ubuntu-latest + steps: + - run: echo version=${{ inputs.version }} +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": { + "type": 2, + "map": [ + { + "Key": "version", + "Value": { + "type": 2, + "map": [ + { + "Key": "type", + "Value": "string" + } + ] + } + } + ] + }, + "InputValues": { + "type": 2, + "map": [ + { + "Key": "version", + "Value": { + "type": 3, + "expr": "matrix.version" + } + } + ] + }, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "version", + "Value": { + "type": 1, + "seq": [ + "v1", + "v2" + ] + } + } + ] + } + } + ] + }, + "Jobs": [ + { + "type": "job", + "id": "build-it", + "name": "build-it", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": { + "type": 3, + "expr": "format('echo version={0}', inputs.version)" + } + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml new file mode 100644 index 0000000..4b4a31c --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml @@ -0,0 +1,126 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + uses: contoso/templates/.github/workflows/build.yml@v1 + build2: + uses: contoso2/templates2/.github/workflows/build2.yml@v1 +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: + workflow_call: +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +contoso2/templates2/.github/workflows/build2.yml@v1 +--- +on: + workflow_call: +jobs: + deploy2: + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + }, + { + "Type": "reusableWorkflowJob", + "Id": "build2", + "Name": "build2", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso2/templates2/.github/workflows/build2.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy2", + "name": "deploy2", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml new file mode 100644 index 0000000..5550cd7 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml @@ -0,0 +1,69 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + uses: contoso/templates/.github/workflows/build.yml@v1 +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: + workflow_call: +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-outputs.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-outputs.yml new file mode 100644 index 0000000..6ef098c --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-outputs.yml @@ -0,0 +1,107 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + uses: contoso/templates/.github/workflows/build.yml@v1 +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: + workflow_call: + outputs: + my-output: + description: Some output + value: ${{ jobs.job1.outputs.hello }} +jobs: + job1: + runs-on: ubuntu-latest + outputs: + hello: hello + steps: + - run: echo 1 +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": null, + "SecretValues": null, + "InheritSecrets": false, + "Outputs": { + "type": 2, + "map": [ + { + "Key": "my-output", + "Value": { + "type": 2, + "map": [ + { + "Key": "description", + "Value": "Some output" + }, + { + "Key": "value", + "Value": { + "type": 3, + "expr": "jobs.job1.outputs.hello" + } + } + ] + } + } + ] + }, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "job1", + "name": "job1", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "outputs": { + "type": 2, + "map": [ + { + "Key": "hello", + "Value": "hello" + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-secrets-secret-without-definition.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-secrets-secret-without-definition.yml new file mode 100644 index 0000000..883d3d6 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-secrets-secret-without-definition.yml @@ -0,0 +1,92 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + uses: contoso/templates/.github/workflows/build.yml@v1 + secrets: + secret1: ${{ secrets.foo }} +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: + workflow_call: + secrets: + secret1: +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": { + "type": 2, + "map": [ + { + "Key": "secret1", + "Value": null + } + ] + }, + "SecretValues": { + "type": 2, + "map": [ + { + "Key": "secret1", + "Value": { + "type": 3, + "expr": "secrets.foo" + } + } + ] + }, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-secrets.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-secrets.yml new file mode 100644 index 0000000..40cda81 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-secrets.yml @@ -0,0 +1,168 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + # Mapping + build: + uses: contoso/templates/.github/workflows/build.yml@v1 + secrets: + secret1: ${{ secrets.foo }} + + # Inherit + build2: + uses: contoso/templates/.github/workflows/build.yml@v1 + secrets: inherit +--- +contoso/templates/.github/workflows/build.yml@v1 +--- +on: + workflow_call: + secrets: + secret1: + required: true +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - run: echo 1 +--- +{ + "jobs": [ + { + "Type": "reusableWorkflowJob", + "Id": "build", + "Name": "build", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": { + "type": 2, + "map": [ + { + "Key": "secret1", + "Value": { + "type": 2, + "map": [ + { + "Key": "required", + "Value": true + } + ] + } + } + ] + }, + "SecretValues": { + "type": 2, + "map": [ + { + "Key": "secret1", + "Value": { + "type": 3, + "expr": "secrets.foo" + } + } + ] + }, + "InheritSecrets": false, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + }, + { + "Type": "reusableWorkflowJob", + "Id": "build2", + "Name": "build2", + "Needs": [], + "If": { + "type": 3, + "expr": "success()" + }, + "Ref": "contoso/templates/.github/workflows/build.yml@v1", + "Permissions": null, + "InputDefinitions": null, + "InputValues": null, + "SecretDefinitions": { + "type": 2, + "map": [ + { + "Key": "secret1", + "Value": { + "type": 2, + "map": [ + { + "Key": "required", + "Value": true + } + ] + } + } + ] + }, + "SecretValues": null, + "InheritSecrets": true, + "Outputs": null, + "Defaults": null, + "Env": null, + "Concurrency": null, + "EmbeddedConcurrency": null, + "Strategy": null, + "Jobs": [ + { + "type": "job", + "id": "deploy", + "name": "deploy", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/root-env-defaults.yml b/actions-workflow-parser/testdata/reader/root-env-defaults.yml new file mode 100644 index 0000000..5b19c66 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/root-env-defaults.yml @@ -0,0 +1,82 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +env: + SERVER: production +defaults: + run: + shell: bash + working-directory: scripts +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: echo hi + continue-on-error: true +--- +{ + "env": { + "type": 2, + "map": [ + { + "Key": "SERVER", + "Value": "production" + } + ] + }, + "defaults": { + "type": 2, + "map": [ + { + "Key": "run", + "Value": { + "type": 2, + "map": [ + { + "Key": "shell", + "Value": "bash" + }, + { + "Key": "working-directory", + "Value": "scripts" + } + ] + } + } + ] + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v3" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "echo hi" + } + ] + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/round-to-infinity.yml b/actions-workflow-parser/testdata/reader/round-to-infinity.yml new file mode 100644 index 0000000..3161e83 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/round-to-infinity.yml @@ -0,0 +1,240 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + positive-infinity-numeric: + runs-on: ubuntu-latest + timeout-minutes: +999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 + steps: + - run: echo hi + negative-infinity-numeric: + runs-on: ubuntu-latest + timeout-minutes: -999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 + steps: + - run: echo hi + positive-infinity-decimal: + runs-on: ubuntu-latest + timeout-minutes: +999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999.12 + steps: + - run: echo hi + NaN: + runs-on: ubuntu-latest + timeout-minutes: ${{ NaN }} + steps: + - run: echo hi + integer-regular: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - run: echo hi + positive-infinity-verbose-serialzation: + runs-on: + - ubuntu-latest + steps: + - run: echo hi + timeout-minutes: 999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 + negative-infinity-numeric-verbose-serialzation: + runs-on: + - ubuntu-latest + steps: + - run: echo hi + timeout-minutes: -999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999 + integer-verbose-serialzation: + runs-on: + - ubuntu-latest + steps: + - run: echo hi + timeout-minutes: 100 +--- +{ + "jobs": [ + { + "type": "job", + "id": "positive-infinity-numeric", + "name": "positive-infinity-numeric", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": "Infinity", + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "negative-infinity-numeric", + "name": "negative-infinity-numeric", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": "-Infinity", + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "positive-infinity-decimal", + "name": "positive-infinity-decimal", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": "Infinity", + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "NaN", + "name": "NaN", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": { + "type": 3, + "expr": "NaN" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "integer-regular", + "name": "integer-regular", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": 10, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "positive-infinity-verbose-serialzation", + "name": "positive-infinity-verbose-serialzation", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 1, + "seq": [ + "ubuntu-latest" + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": "Infinity", + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "negative-infinity-numeric-verbose-serialzation", + "name": "negative-infinity-numeric-verbose-serialzation", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 1, + "seq": [ + "ubuntu-latest" + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": "-Infinity", + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "integer-verbose-serialzation", + "name": "integer-verbose-serialzation", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": { + "type": 1, + "seq": [ + "ubuntu-latest" + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "timeout-minutes": 100, + "run": "echo hi" + } + ] + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/step-continue-on-error.yml b/actions-workflow-parser/testdata/reader/step-continue-on-error.yml new file mode 100644 index 0000000..7a103ed --- /dev/null +++ b/actions-workflow-parser/testdata/reader/step-continue-on-error.yml @@ -0,0 +1,55 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: exit 2 + continue-on-error: true + - run: exit 1 + continue-on-error: false + - run: exit 1 +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": true, + "run": "exit 2" + }, + { + "id": "__run_2", + "if": { + "type": 3, + "expr": "success()" + }, + "continue-on-error": false, + "run": "exit 1" + }, + { + "id": "__run_3", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "exit 1" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/step-id-generates-counter-per-collision.yml b/actions-workflow-parser/testdata/reader/step-id-generates-counter-per-collision.yml new file mode 100644 index 0000000..0713335 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/step-id-generates-counter-per-collision.yml @@ -0,0 +1,96 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + - uses: docker://alpine:latest + - run: echo hello + - uses: actions/checkout@v2 + - uses: actions/setup-node@v3 + - uses: docker://alpine:latest + - run: echo hello +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v2" + }, + { + "id": "__actions_setup-node", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/setup-node@v2" + }, + { + "id": "__alpine_latest", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "docker://alpine:latest" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hello" + }, + { + "id": "__actions_checkout_2", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v2" + }, + { + "id": "__actions_setup-node_2", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/setup-node@v3" + }, + { + "id": "__alpine_latest_2", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "docker://alpine:latest" + }, + { + "id": "__run_2", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hello" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/step-id-generates-when-empty-and-considers-collisions.yml b/actions-workflow-parser/testdata/reader/step-id-generates-when-empty-and-considers-collisions.yml new file mode 100644 index 0000000..0713335 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/step-id-generates-when-empty-and-considers-collisions.yml @@ -0,0 +1,96 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + - uses: docker://alpine:latest + - run: echo hello + - uses: actions/checkout@v2 + - uses: actions/setup-node@v3 + - uses: docker://alpine:latest + - run: echo hello +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v2" + }, + { + "id": "__actions_setup-node", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/setup-node@v2" + }, + { + "id": "__alpine_latest", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "docker://alpine:latest" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hello" + }, + { + "id": "__actions_checkout_2", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v2" + }, + { + "id": "__actions_setup-node_2", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/setup-node@v3" + }, + { + "id": "__alpine_latest_2", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "docker://alpine:latest" + }, + { + "id": "__run_2", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hello" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/step-id-generates-when-empty.yml b/actions-workflow-parser/testdata/reader/step-id-generates-when-empty.yml new file mode 100644 index 0000000..8a9297a --- /dev/null +++ b/actions-workflow-parser/testdata/reader/step-id-generates-when-empty.yml @@ -0,0 +1,60 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + my-job: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: org with space/repo with space@v2 + - uses: docker://alpine:latest + - run: echo hello +--- +{ + "jobs": [ + { + "type": "job", + "id": "my-job", + "name": "my-job", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v2" + }, + { + "id": "__org_with_space_repo_with_space", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "org with space/repo with space@v2" + }, + { + "id": "__alpine_latest", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "docker://alpine:latest" + }, + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hello" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/step-id.yml b/actions-workflow-parser/testdata/reader/step-id.yml new file mode 100644 index 0000000..be06dd1 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/step-id.yml @@ -0,0 +1,44 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + job1: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + id: action-step-id + - run: echo 1 + id: run-step-id +--- +{ + "jobs": [ + { + "type": "job", + "id": "job1", + "name": "job1", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "action-step-id", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v1" + }, + { + "id": "run-step-id", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo 1" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/step-if.yml b/actions-workflow-parser/testdata/reader/step-if.yml new file mode 100644 index 0000000..ffbaf89 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/step-if.yml @@ -0,0 +1,193 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + if: github.repository == 'octo-org/octo-repo-prod' + runs-on: ubuntu-latest + env: + super_secret: ${{ secrets.SuperSecret }} + steps: + - run: echo hi + - if: always() + run: echo hi + - if: 123 + run: echo hi + - if: 0 || 1 + run: echo hi + - if: 123 || failure() + run: echo hi + - if: ${{ 0 || 1 }} + run: echo hi + - if: ${{ 123 || failure() }} + run: echo hi + - if: ${{ github.ref == 'refs/heads/main' }} # github context + run: echo hi + - if: ${{ vars.foo == 'bar' }} # vars context + run: echo hi + - if: ${{ vars.foo || success() }} # should not prepend "success()" + run: echo hi + - if: ${{ true || 'success()' }} # should prepend "success()" + run: echo hi + - if: ${{ env.super_secret != '' }} + run: echo 'This step will only run if the secret has a value set.' + - if: ${{ env.super_secret == '' }} + run: echo 'This step will only run if the secret does not have a value set.' + - name: My first step + uses: octo-org/action-name@main + - name: My backup step + if: ${{ needs.build.outputs.run-js-tests && success() }} + uses: actions/heroku@1.0.0 +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success() && (github.repository == 'octo-org/octo-repo-prod')" + }, + "env": { + "type": 2, + "map": [ + { + "Key": "super_secret", + "Value": { + "type": 3, + "expr": "secrets.SuperSecret" + } + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + }, + { + "id": "__run_2", + "if": { + "type": 3, + "expr": "always()" + }, + "run": "echo hi" + }, + { + "id": "__run_3", + "if": { + "type": 3, + "expr": "success() && (123)" + }, + "run": "echo hi" + }, + { + "id": "__run_4", + "if": { + "type": 3, + "expr": "success() && (0 || 1)" + }, + "run": "echo hi" + }, + { + "id": "__run_5", + "if": { + "type": 3, + "expr": "123 || failure()" + }, + "run": "echo hi" + }, + { + "id": "__run_6", + "if": { + "type": 3, + "expr": "success() && (0 || 1)" + }, + "run": "echo hi" + }, + { + "id": "__run_7", + "if": { + "type": 3, + "expr": "123 || failure()" + }, + "run": "echo hi" + }, + { + "id": "__run_8", + "if": { + "type": 3, + "expr": "success() && (github.ref == 'refs/heads/main')" + }, + "run": "echo hi" + }, + { + "id": "__run_9", + "if": { + "type": 3, + "expr": "success() && (vars.foo == 'bar')" + }, + "run": "echo hi" + }, + { + "id": "__run_10", + "if": { + "type": 3, + "expr": "vars.foo || success()" + }, + "run": "echo hi" + }, + { + "id": "__run_11", + "if": { + "type": 3, + "expr": "success() && (true || 'success()')" + }, + "run": "echo hi" + }, + { + "id": "__run_12", + "if": { + "type": 3, + "expr": "success() && (env.super_secret != '')" + }, + "run": "echo 'This step will only run if the secret has a value set.'" + }, + { + "id": "__run_13", + "if": { + "type": 3, + "expr": "success() && (env.super_secret == '')" + }, + "run": "echo 'This step will only run if the secret does not have a value set.'" + }, + { + "id": "__octo-org_action-name", + "name": "My first step", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "octo-org/action-name@main" + }, + { + "id": "__actions_heroku", + "name": "My backup step", + "if": { + "type": 3, + "expr": "needs.build.outputs.run-js-tests && success()" + }, + "uses": "actions/heroku@1.0.0" + } + ] + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/step-name.yml b/actions-workflow-parser/testdata/reader/step-name.yml new file mode 100644 index 0000000..e231fe6 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/step-name.yml @@ -0,0 +1,67 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hello world + - uses: actions/setup-node@master + - run: echo hello world + name: My Step Name + - uses: actions/setup-node@master + name: ${{ github.sha }} +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hello world" + }, + { + "id": "__actions_setup-node", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/setup-node@master" + }, + { + "id": "__run_2", + "name": "My Step Name", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hello world" + }, + { + "id": "__actions_setup-node_2", + "name": { + "type": 3, + "expr": "github.sha" + }, + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/setup-node@master" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/step-run.yml b/actions-workflow-parser/testdata/reader/step-run.yml new file mode 100644 index 0000000..afca5f3 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/step-run.yml @@ -0,0 +1,60 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ${{ github.sha }} # github context + - run: echo ${{ vars.foo }} # vars context + - run: echo ${{ steps.foo }} # steps context +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": { + "type": 3, + "expr": "format('echo {0}', github.sha)" + } + }, + { + "id": "__run_2", + "if": { + "type": 3, + "expr": "success()" + }, + "run": { + "type": 3, + "expr": "format('echo {0}', vars.foo)" + } + }, + { + "id": "__run_3", + "if": { + "type": 3, + "expr": "success()" + }, + "run": { + "type": 3, + "expr": "format('echo {0}', steps.foo)" + } + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/workflow-concurrency-expression.yml b/actions-workflow-parser/testdata/reader/workflow-concurrency-expression.yml new file mode 100644 index 0000000..ca9f216 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/workflow-concurrency-expression.yml @@ -0,0 +1,38 @@ +include-source: false # Drop file/line/col from output +--- +on: push +concurrency: ci-${{ github.ref }} +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "concurrency": { + "type": 3, + "expr": "format('ci-{0}', github.ref)" + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/workflow-concurrency-mapping.yml b/actions-workflow-parser/testdata/reader/workflow-concurrency-mapping.yml new file mode 100644 index 0000000..d3399f4 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/workflow-concurrency-mapping.yml @@ -0,0 +1,52 @@ +include-source: false # Drop file/line/col from output +--- +on: push +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "concurrency": { + "type": 2, + "map": [ + { + "Key": "group", + "Value": { + "type": 3, + "expr": "format('ci-{0}', github.ref)" + } + }, + { + "Key": "cancel-in-progress", + "Value": true + } + ] + }, + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/workflow-defaults.yml b/actions-workflow-parser/testdata/reader/workflow-defaults.yml new file mode 100644 index 0000000..51fb7f8 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/workflow-defaults.yml @@ -0,0 +1,60 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +defaults: + run: + shell: cmd + working-directory: foo +jobs: + one: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "defaults": { + "type": 2, + "map": [ + { + "Key": "run", + "Value": { + "type": 2, + "map": [ + { + "Key": "shell", + "Value": "cmd" + }, + { + "Key": "working-directory", + "Value": "foo" + } + ] + } + } + ] + }, + "jobs": [ + { + "type": "job", + "id": "one", + "name": "one", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/workflow-env.yml b/actions-workflow-parser/testdata/reader/workflow-env.yml new file mode 100644 index 0000000..8c223a4 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/workflow-env.yml @@ -0,0 +1,66 @@ +include-source: false # Drop file/line/col from output +--- +on: push +env: + root-env: root env value +jobs: + one: + runs-on: windows-2019 + env: + job-env: job env value + steps: + - run: echo 1 + env: + step-env: step env value +--- +{ + "env": { + "type": 2, + "map": [ + { + "Key": "root-env", + "Value": "root env value" + } + ] + }, + "jobs": [ + { + "type": "job", + "id": "one", + "name": "one", + "if": { + "type": 3, + "expr": "success()" + }, + "env": { + "type": 2, + "map": [ + { + "Key": "job-env", + "Value": "job env value" + } + ] + }, + "runs-on": "windows-2019", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "env": { + "type": 2, + "map": [ + { + "Key": "step-env", + "Value": "step env value" + } + ] + }, + "run": "echo 1" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/workflow-name.yml b/actions-workflow-parser/testdata/reader/workflow-name.yml new file mode 100644 index 0000000..6ba12b5 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/workflow-name.yml @@ -0,0 +1,34 @@ +include-source: false # Drop file/line/col from output +--- +on: push +name: my-workflow-name +jobs: + one: + runs-on: ubuntu-latest + steps: + - run: echo hello +--- +{ + "jobs": [ + { + "type": "job", + "id": "one", + "name": "one", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hello" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-bool.yml b/actions-workflow-parser/testdata/reader/yaml-schema-bool.yml new file mode 100644 index 0000000..b0bbd06 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/yaml-schema-bool.yml @@ -0,0 +1,83 @@ +include-source: false # Drop file/line/col from output +skip: + - Go +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + # Values are based on YAML 1.2 core schema - https://yaml.org/spec/1.2.0/#id2604037 + vector-1: + # Boolean, implicit type + - true + - True + - TRUE + - false + - False + - FALSE + + # Boolean, explicit type + - !!bool true + - !!bool FALSE + - ! true + - ! FALSE + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "vector-1", + "Value": { + "type": 1, + "seq": [ + true, + true, + true, + false, + false, + false, + true, + false, + true, + false + ] + } + } + ] + } + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-float.yml b/actions-workflow-parser/testdata/reader/yaml-schema-float.yml new file mode 100644 index 0000000..2531ab8 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/yaml-schema-float.yml @@ -0,0 +1,190 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + # Values are based on YAML 1.2 core schema - https://yaml.org/spec/1.2.0/#id2604037 + vector-1: + # Float, implicit type + - .5 + - 0.5 + - -.5 + - +.5 + - -0.5 + - +0.5 + - -2.0 + - +2.0 + - 0.0 + - -0.0 + - +0.0 + - 123456.789 + - +123456.789 + - -123456.789 + - 0.84551240822557006 + - 1.2e2 + - 1.2E2 + - 1.2e+2 + - 1.2E+2 + - 1.2e-2 + - 1.2E-2 + - +1.2e2 + - +1.2E2 + - +1.2e+2 + - +1.2E+2 + - +1.2e-2 + - +1.2E-2 + - -1.2e2 + - -1.2E2 + - -1.2e+2 + - -1.2E+2 + - -1.2e-2 + - -1.2E-2 + - .inf + - .Inf + - .INF + - +.inf + - +.Inf + - +.INF + - -.inf + - -.Inf + - -.INF + - .nan + - .NaN + - .NAN + + # Float, explicit type + - !!float 1 + - !!float 2 + - !!float -1 + - !!float +1 + - !!float 0 + - !!float -0 + - !!float +0 + - !!float .Inf + - !!float .NaN + - ! 1 + - ! 2 + - ! -1 + - ! +1 + - ! 0 + - ! -0 + - ! +0 + - ! .Inf + - ! .NaN + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "vector-1", + "Value": { + "type": 1, + "seq": [ + 0.5, + 0.5, + -0.5, + 0.5, + -0.5, + 0.5, + -2, + 2, + 0, + 0, + 0, + 123456.789, + 123456.789, + -123456.789, + 0.8455124082255701, + 120, + 120, + 120, + 120, + 0.012, + 0.012, + 120, + 120, + 120, + 120, + 0.012, + 0.012, + -120, + -120, + -120, + -120, + -0.012, + -0.012, + "Infinity", + "Infinity", + "Infinity", + "Infinity", + "Infinity", + "Infinity", + "-Infinity", + "-Infinity", + "-Infinity", + "NaN", + "NaN", + "NaN", + 1, + 2, + -1, + 1, + 0, + 0, + 0, + "Infinity", + "NaN", + 1, + 2, + -1, + 1, + 0, + 0, + 0, + "Infinity", + "NaN" + ] + } + } + ] + } + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-int.yml b/actions-workflow-parser/testdata/reader/yaml-schema-int.yml new file mode 100644 index 0000000..c571fa3 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/yaml-schema-int.yml @@ -0,0 +1,103 @@ +include-source: false # Drop file/line/col from output +skip: + - Go +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + # Values are based on YAML 1.2 core schema - https://yaml.org/spec/1.2.0/#id2604037 + vector-1: + # Integer, implicit type + - 1 + - 2 + - -1 + - +1 + - 0 + - -0 + - +0 + - 0xff + - 0xFF + - 0o10 + + # Integer, explicit type + - !!int 1 + - !!int -1 + - !!int +1 + - !!int 0xff + - !!int 0o10 + - ! 1 + - ! -1 + - ! +1 + - ! 0xff + - ! 0o10 + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "vector-1", + "Value": { + "type": 1, + "seq": [ + 1, + 2, + -1, + 1, + 0, + 0, + 0, + 255, + 255, + 8, + 1, + -1, + 1, + 255, + 8, + 1, + -1, + 1, + 255, + 8 + ] + } + } + ] + } + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-null.yml b/actions-workflow-parser/testdata/reader/yaml-schema-null.yml new file mode 100644 index 0000000..86d09a0 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/yaml-schema-null.yml @@ -0,0 +1,84 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + # Values are based on YAML 1.2 core schema - https://yaml.org/spec/1.2.0/#id2604037 + vector-1: + # Null, implicit type + - null + - Null + - NULL + - ~ + + # Null, explicit type + - !!null + - !!null null + - !!null ~ + - ! + - ! null + - ! ~ + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "vector-1", + "Value": { + "type": 1, + "seq": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + } + } + ] + } + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-sequence.yml b/actions-workflow-parser/testdata/reader/yaml-schema-sequence.yml new file mode 100644 index 0000000..c285c3e --- /dev/null +++ b/actions-workflow-parser/testdata/reader/yaml-schema-sequence.yml @@ -0,0 +1,84 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + # Values are based on YAML 1.2 core schema - https://yaml.org/spec/1.2.0/#id2604037 + vector-1: + # Customer-breaking change we discovered when upgrading from YamlDotNet.Signed 6.0.0 to YamlDotNet 12.0.2. + # YamlDotNet 12.0.2 produces the error "While scanning a flow sequence end, found invalid comment after ']'." + - [one]#, two, three ] + + # Customer-breaking change we discovered when upgrading from YamlDotNet.Signed 6.0.0 to YamlDotNet 12.0.2. + # YamlDotNet 12.0.2 produces the error "Exception of type 'System.Exception' was thrown." + - [one, two, + three, four] + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "vector-1", + "Value": { + "type": 1, + "seq": [ + { + "type": 1, + "seq": [ + "one" + ] + }, + { + "type": 1, + "seq": [ + "one", + "two", + "three", + "four" + ] + } + ] + } + } + ] + } + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-str-block-styles.yml b/actions-workflow-parser/testdata/reader/yaml-schema-str-block-styles.yml new file mode 100644 index 0000000..e474ae6 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/yaml-schema-str-block-styles.yml @@ -0,0 +1,244 @@ +include-source: false # Drop file/line/col from output +skip: + - Go +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + # Values are based on YAML 1.2 core schema - https://yaml.org/spec/1.2.0/#id2604037 + vector-1: + # String, implicit type, block style + # + # Scalar style: + # | "literal" block (keep newlines) + # > "folded" block (replace newlines with spaces) + # + # Chomp style: + # (empty) clip (remove all trailing newlines except the first) + # - strip (remove all trailing newlines) + # + keep (retain all trailing newlines) + # + + # literal, clip, implicit indent + - | + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # literal, strip, implicit indent + - |- + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # literal, keep, implicit indent + - |+ + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # literal, clip, explicit indent + - |2 + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # literal, strip, explicit indent + - |-2 + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # literal, keep, explicit indent + - |+2 + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # folded, clip, implicit indent + - > + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # folded, strip, implicit indent + - >- + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # folded, keep, implicit indent + - >+ + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # folded, clip, explicit indent + - >2 + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # folded, strip, explicit indent + - >-2 + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # folded, keep, explicit indent + - >+2 + Line 1 + Line 2 + + Line 4 + Line 5 + Line 6 + Line 7 + + + # String, explicit type, block style + - !!str | + hello + world + - ! | + hello + world + + # Customer-breaking change we discovered when upgrading from YamlDotNet.Signed 6.0.0 to YamlDotNet 12.0.2. + # YamlDotNet 12.0.2 produces the error "While scanning a literal block scalar, found extra spaces in first line." + # + # Note, the first line is empty. + - | + + hello + + # Customer-breaking change we discovered when upgrading from YamlDotNet.Signed 6.0.0 to YamlDotNet 12.0.2. + # YamlDotNet 12.0.2 produces the error "While scanning a literal block scalar, found extra spaces in first line." + # + # Note, the first line is whitespace. + - | + + hello + + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "vector-1", + "Value": { + "type": 1, + "seq": [ + "Line 1\nLine 2\n\nLine 4\n Line 5\nLine 6\nLine 7\n", + "Line 1\nLine 2\n\nLine 4\n Line 5\nLine 6\nLine 7", + "Line 1\nLine 2\n\nLine 4\n Line 5\nLine 6\nLine 7\n\n\n", + "Line 1\nLine 2\n\nLine 4\n Line 5\nLine 6\nLine 7\n", + "Line 1\nLine 2\n\nLine 4\n Line 5\nLine 6\nLine 7", + "Line 1\nLine 2\n\nLine 4\n Line 5\nLine 6\nLine 7\n\n\n", + "Line 1 Line 2\nLine 4\n Line 5\nLine 6 Line 7\n", + "Line 1 Line 2\nLine 4\n Line 5\nLine 6 Line 7", + "Line 1 Line 2\nLine 4\n Line 5\nLine 6 Line 7\n\n\n", + "Line 1 Line 2\nLine 4\n Line 5\nLine 6 Line 7\n", + "Line 1 Line 2\nLine 4\n Line 5\nLine 6 Line 7", + "Line 1 Line 2\nLine 4\n Line 5\nLine 6 Line 7\n\n\n", + "hello\nworld\n", + "hello\nworld\n", + "\nhello\n", + "\nhello\n" + ] + } + } + ] + } + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-str-flow-styles.yml b/actions-workflow-parser/testdata/reader/yaml-schema-str-flow-styles.yml new file mode 100644 index 0000000..092a72f --- /dev/null +++ b/actions-workflow-parser/testdata/reader/yaml-schema-str-flow-styles.yml @@ -0,0 +1,107 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + # Values are based on YAML 1.2 core schema - https://yaml.org/spec/1.2.0/#id2604037 + vector-1: + # String, implicit type + - NaN + - Infinity + - -Infinity + - foo bar # Unquoted + - "foo bar" # Double quotes + - 'foo bar' # Single quotes + + # String, explicit type + - !!str null + - !!str true + - !!str 0 + - !!str foo bar + - !!str \"foo bar\" + - !!str 'foo bar' + - ! null + - ! true + - ! 0 + - ! foo bar + - ! \"foo bar\" + - ! 'foo bar' + + # Customer-breaking change we discovered when upgrading from YamlDotNet.Signed 6.0.0 to YamlDotNet 12.0.2. + # YamlDotNet 12.0.2 produces the error "While scanning a multi-line double-quoted scalar, found wrong indentation." + - "hello + world" + + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "vector-1", + "Value": { + "type": 1, + "seq": [ + "NaN", + "Infinity", + "-Infinity", + "foo bar", + "foo bar", + "foo bar", + "null", + "true", + "0", + "foo bar", + "\\\"foo bar\\\"", + "foo bar", + "null", + "true", + "0", + "foo bar", + "\\\"foo bar\\\"", + "foo bar", + "hello world" + ] + } + } + ] + } + } + ] + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-string.yml b/actions-workflow-parser/testdata/reader/yaml-schema-string.yml new file mode 100644 index 0000000..41ed76d --- /dev/null +++ b/actions-workflow-parser/testdata/reader/yaml-schema-string.yml @@ -0,0 +1,56 @@ +include-source: false # Drop file/line/col from output +skip: +- TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: github/checkout@v2.5 + with: + dot: . + single-quoted-number: '8' + double-quoted-number: "9" +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__github_checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "github/checkout@v2.5", + "with": { + "type": 2, + "map": [ + { + "Key": "dot", + "Value": "." + }, + { + "Key": "single-quoted-number", + "Value": "8" + }, + { + "Key": "double-quoted-number", + "Value": "9" + } + ] + } + } + ] + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-timestamp.yml b/actions-workflow-parser/testdata/reader/yaml-schema-timestamp.yml new file mode 100644 index 0000000..26ab729 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/yaml-schema-timestamp.yml @@ -0,0 +1,63 @@ +include-source: false # Drop file/line/col from output +skip: +- TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: github/issue-labeler@v2.5 + with: + # Values are from YAML 1.2 core schema - https://yaml.org/spec/1.2.0/#id2560726 + # Tags are not explicitly set to !!timestamp, so these are parsed as strings. + canonical: 2001-12-15T02:59:43.1Z + iso8601: 2001-12-14t21:59:43.10-05:00 + spaced: 2001-12-14 21:59:43.10 -5 + date: 2002-12-14 +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__github_issue-labeler", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "github/issue-labeler@v2.5", + "with": { + "type": 2, + "map": [ + { + "Key": "canonical", + "Value": "2001-12-15T02:59:43.1Z" + }, + { + "Key": "iso8601", + "Value": "2001-12-14t21:59:43.10-05:00" + }, + { + "Key": "spaced", + "Value": "2001-12-14 21:59:43.10 -5" + }, + { + "Key": "date", + "Value": "2002-12-14" + } + ] + } + } + ] + } + ] +} diff --git a/actions-workflow-parser/tsconfig.build.json b/actions-workflow-parser/tsconfig.build.json new file mode 100644 index 0000000..88bdfa8 --- /dev/null +++ b/actions-workflow-parser/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "exclude": ["./src/**/*.test.ts"], + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "./dist" + } +} diff --git a/actions-workflow-parser/tsconfig.json b/actions-workflow-parser/tsconfig.json new file mode 100644 index 0000000..61fae15 --- /dev/null +++ b/actions-workflow-parser/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": ["src"], + "compilerOptions": { + "module": "esnext", + "target": "ES2020", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "watchOptions": { + "watchFile": "useFsEvents", + "watchDirectory": "useFsEvents", + "synchronousWatchDirectory": true, + "excludeDirectories": ["**/node_modules", "dist"] + } +} diff --git a/actions-workflow-parser/workflow-v1.0.json b/actions-workflow-parser/workflow-v1.0.json new file mode 100644 index 0000000..9613bd7 --- /dev/null +++ b/actions-workflow-parser/workflow-v1.0.json @@ -0,0 +1,2466 @@ +{ + "version": "workflow-v1.0", + "definitions": { + "workflow-root": { + "description": "A workflow file.", + "mapping": { + "properties": { + "on": "on", + "name": "workflow-name", + "run-name": "run-name", + "defaults": "workflow-defaults", + "env": "workflow-env", + "permissions": "permissions", + "concurrency": "workflow-concurrency", + "jobs": { + "type": "jobs", + "required": true + } + } + } + }, + "workflow-root-strict": { + "description": "Workflow file with strict validation", + "mapping": { + "properties": { + "on": { + "type": "on-strict", + "required": true + }, + "name": "workflow-name", + "run-name": "run-name", + "defaults": "workflow-defaults", + "env": "workflow-env", + "permissions": "permissions", + "concurrency": "workflow-concurrency", + "jobs": { + "type": "jobs", + "required": true + } + } + } + }, + "workflow-name": { + "description": "The name of the workflow.", + "string": {} + }, + "run-name": { + "context": [ + "github", + "inputs", + "vars" + ], + "string": {}, + "description": "The name for workflow runs generated from the workflow. GitHub displays the workflow run name in the list of workflow runs on your repository's 'Actions' tab." + }, + "on": { + "description": "The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows.", + "one-of": [ + "string", + "sequence", + "on-mapping" + ] + }, + "on-mapping": { + "mapping": { + "properties": { + "workflow_call": "workflow-call" + }, + "loose-key-type": "non-empty-string", + "loose-value-type": "any" + } + }, + "on-strict": { + "description": "The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows.", + "one-of": [ + "on-string-strict", + "on-sequence-strict", + "on-mapping-strict" + ] + }, + "on-mapping-strict": { + "description": "The name of the GitHub event that triggers the workflow. You can provide a single event string, array of events, array of event types, or an event configuration map that schedules a workflow or restricts the execution of a workflow to specific files, tags, or branch changes. For a list of available events, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/events-that-trigger-workflows.", + "mapping": { + "properties": { + "branch_protection_rule": "branch-protection-rule", + "check_run": "check-run", + "check_suite": "check-suite", + "create": "create", + "delete": "delete", + "deployment": "deployment", + "deployment_status": "deployment-status", + "discussion": "discussion", + "discussion_comment": "discussion-comment", + "fork": "fork", + "gollum": "gollum", + "issue_comment": "issue-comment", + "issues": "issues", + "label": "label", + "merge_group": "merge-group", + "milestone": "milestone", + "page_build": "page-build", + "project": "project", + "project_card": "project-card", + "project_column": "project-column", + "public": "public", + "pull_request": "pull-request", + "pull_request_comment": "pull-request-comment", + "pull_request_review": "pull-request-review", + "pull_request_review_comment": "pull-request-review-comment", + "pull_request_target": "pull-request-target", + "push": "push", + "registry_package": "registry-package", + "release": "release", + "repository_dispatch": "repository-dispatch", + "schedule": "schedule", + "status": "status", + "watch": "watch", + "workflow_call": "workflow-call", + "workflow_dispatch": "workflow-dispatch", + "workflow_run": "workflow-run" + } + } + }, + "on-string-strict": { + "one-of": [ + "branch-protection-rule-string", + "check-run-string", + "check-suite-string", + "create-string", + "delete-string", + "deployment-string", + "deployment-status-string", + "discussion-string", + "discussion-comment-string", + "fork-string", + "gollum-string", + "issue-comment-string", + "issues-string", + "label-string", + "merge-group-string", + "milestone-string", + "page-build-string", + "project-string", + "project-card-string", + "project-column-string", + "public-string", + "pull-request-string", + "pull-request-comment-string", + "pull-request-review-string", + "pull-request-review-comment-string", + "pull-request-target-string", + "push-string", + "registry-package-string", + "release-string", + "repository-dispatch-string", + "schedule-string", + "status-string", + "watch-string", + "workflow-call-string", + "workflow-dispatch-string", + "workflow-run-string" + ] + }, + "on-sequence-strict": { + "sequence": { + "item-type": "on-string-strict" + } + }, + "branch-protection-rule-string": { + "description": "Runs your workflow when branch protection rules in the workflow repository are changed.", + "string": { + "constant": "branch_protection_rule" + } + }, + "branch-protection-rule": { + "description": "Runs your workflow when branch protection rules in the workflow repository are changed.", + "one-of": [ + "null", + "branch-protection-rule-mapping" + ] + }, + "branch-protection-rule-mapping": { + "mapping": { + "properties": { + "types": "branch-protection-rule-activity" + } + } + }, + "branch-protection-rule-activity": { + "description": "The types of branch protection rule activity that trigger the workflow. Can be one or more of: created, edited, deleted.", + "one-of": [ + "branch-protection-rule-activity-type", + "branch-protection-rule-activity-types" + ] + }, + "branch-protection-rule-activity-types": { + "sequence": { + "item-type": "branch-protection-rule-activity-type" + } + }, + "branch-protection-rule-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "check-run-string": { + "description": "Runs your workflow when activity related to a check run occurs. A check run is an individual test that is part of a check suite.", + "string": { + "constant": "check_run" + } + }, + "check-run": { + "description": "Runs your workflow when activity related to a check run occurs. A check run is an individual test that is part of a check suite.", + "one-of": [ + "null", + "check-run-mapping" + ] + }, + "check-run-mapping": { + "mapping": { + "properties": { + "types": "check-run-activity" + } + } + }, + "check-run-activity": { + "description": "The types of check run activity that trigger the workflow. Can be one or more of: completed, requested, rerequested, or requested-action.", + "one-of": [ + "check-run-activity-type", + "check-run-activity-types" + ] + }, + "check-run-activity-types": { + "sequence": { + "item-type": "check-run-activity-type" + } + }, + "check-run-activity-type": { + "allowed-values": [ + "completed", + "created", + "rerequested", + "requested_action" + ] + }, + "check-suite-string": { + "description": "Runs your workflow when check suite activity occurs. A check suite is a collection of the check runs created for a specific commit. Check suites summarize the status and conclusion of the check runs that are in the suite.", + "string": { + "constant": "check_suite" + } + }, + "check-suite": { + "description": "Runs your workflow when check suite activity occurs. A check suite is a collection of the check runs created for a specific commit. Check suites summarize the status and conclusion of the check runs that are in the suite.", + "one-of": [ + "null", + "check-suite-mapping" + ] + }, + "check-suite-mapping": { + "mapping": { + "properties": { + "types": "check-suite-activity" + } + } + }, + "check-suite-activity": { + "description": "The types of check suite activity that trigger the workflow. Currently only completed is supported.", + "one-of": [ + "check-suite-activity-type", + "check-suite-activity-types" + ] + }, + "check-suite-activity-types": { + "sequence": { + "item-type": "check-suite-activity-type" + } + }, + "check-suite-activity-type": { + "allowed-values": [ + "completed" + ] + }, + "create-string": { + "description": "Runs your workflow when someone creates a Git reference (Git branch or tag) in the workflow's repository.", + "string": { + "constant": "create" + } + }, + "create": { + "description": "Runs your workflow when someone creates a Git reference (Git branch or tag) in the workflow's repository.", + "null": {} + }, + "delete-string": { + "description": "Runs your workflow when someone deletes a Git reference (Git branch or tag) in the workflow's repository.", + "string": { + "constant": "delete" + } + }, + "delete": { + "description": "Runs your workflow when someone deletes a Git reference (Git branch or tag) in the workflow's repository.", + "null": {} + }, + "deployment-string": { + "description": "Runs your workflow when someone creates a deployment in the workflow's repository. Deployments created with a commit SHA may not have a Git ref.", + "string": { + "constant": "deployment" + } + }, + "deployment": { + "description": "Runs your workflow when someone creates a deployment in the workflow's repository. Deployments created with a commit SHA may not have a Git ref.", + "null": {} + }, + "deployment-status-string": { + "description": "Runs your workflow when a third party provides a deployment status. Deployments created with a commit SHA may not have a Git ref.", + "string": { + "constant": "deployment_status" + } + }, + "deployment-status": { + "description": "Runs your workflow when a third party provides a deployment status. Deployments created with a commit SHA may not have a Git ref.", + "null": {} + }, + "discussion-string": { + "description": "Runs your workflow when a discussion in the workflow's repository is created or modified. For activity related to comments on a discussion, use the discussion_comment event.", + "string": { + "constant": "discussion" + } + }, + "discussion": { + "description": "Runs your workflow when a discussion in the workflow's repository is created or modified. For activity related to comments on a discussion, use the discussion_comment event.", + "one-of": [ + "null", + "discussion-mapping" + ] + }, + "discussion-mapping": { + "mapping": { + "properties": { + "types": "discussion-activity" + } + } + }, + "discussion-activity": { + "description": "The types of discussion activity that trigger the workflow. Can be one or more of: created, edited, deleted, transferred, pinned, unpinned, labeled, unlabeled, locked, unlocked, category_changed, answered, or unanswered.", + "one-of": [ + "discussion-activity-type", + "discussion-activity-types" + ] + }, + "discussion-activity-types": { + "sequence": { + "item-type": "discussion-activity-type" + } + }, + "discussion-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted", + "transferred", + "pinned", + "unpinned", + "labeled", + "unlabeled", + "locked", + "unlocked", + "category_changed", + "answered", + "unanswered" + ] + }, + "discussion-comment-string": { + "description": "Runs your workflow when a comment on a discussion in the workflow's repository is created or modified. For activity related to a discussion as opposed to comments on the discussion, use the discussion event.", + "string": { + "constant": "discussion_comment" + } + }, + "discussion-comment": { + "description": "Runs your workflow when a comment on a discussion in the workflow's repository is created or modified. For activity related to a discussion as opposed to comments on the discussion, use the discussion event.", + "one-of": [ + "null", + "discussion-comment-mapping" + ] + }, + "discussion-comment-mapping": { + "mapping": { + "properties": { + "types": "discussion-comment-activity" + } + } + }, + "discussion-comment-activity": { + "description": "The types of discussion comment activity that trigger the workflow. Can be one or more of: created, edited, or deleted.", + "one-of": [ + "discussion-comment-activity-type", + "discussion-comment-activity-types" + ] + }, + "discussion-comment-activity-types": { + "sequence": { + "item-type": "discussion-comment-activity-type" + } + }, + "discussion-comment-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "fork-string": { + "description": "Runs your workflow when someone forks a repository.", + "string": { + "constant": "fork" + } + }, + "fork": { + "description": "Runs your workflow when someone forks a repository.", + "null": {} + }, + "gollum-string": { + "description": "Runs your workflow when someone creates or updates a Wiki page.", + "string": { + "constant": "gollum" + } + }, + "gollum": { + "description": "Runs your workflow when someone creates or updates a Wiki page.", + "null": {} + }, + "issue-comment-string": { + "description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.", + "string": { + "constant": "issue_comment" + } + }, + "issue-comment": { + "description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.", + "one-of": [ + "null", + "issue-comment-mapping" + ] + }, + "issue-comment-mapping": { + "mapping": { + "properties": { + "types": "issue-comment-activity" + } + } + }, + "issue-comment-activity": { + "description": "The types of issue comment activity that trigger the workflow. Can be one or more of: created, edited, or deleted.", + "one-of": [ + "issue-comment-activity-type", + "issue-comment-activity-types" + ] + }, + "issue-comment-activity-types": { + "sequence": { + "item-type": "issue-comment-activity-type" + } + }, + "issue-comment-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "issues-string": { + "description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.", + "string": { + "constant": "issues" + } + }, + "issues": { + "description": "Runs your workflow when an issue or pull request comment is created, edited, or deleted.", + "one-of": [ + "null", + "issues-mapping" + ] + }, + "issues-mapping": { + "mapping": { + "properties": { + "types": "issues-activity" + } + } + }, + "issues-activity": { + "description": "The types of issue activity that trigger the workflow. Can be one or more of: opened, edited, deleted, transferred, pinned, unpinned, closed, reopened, assigned, unassigned, labeled, unlabeled, locked, unlocked, milestoned, or demilestoned.", + "one-of": [ + "issues-activity-type", + "issues-activity-types" + ] + }, + "issues-activity-types": { + "sequence": { + "item-type": "issues-activity-type" + } + }, + "issues-activity-type": { + "allowed-values": [ + "opened", + "edited", + "deleted", + "transferred", + "pinned", + "unpinned", + "closed", + "reopened", + "assigned", + "unassigned", + "labeled", + "unlabeled", + "locked", + "unlocked", + "milestoned", + "demilestoned" + ] + }, + "label-string": { + "description": "Runs your workflow when a label in your workflow's repository is created or modified.", + "string": { + "constant": "label" + } + }, + "label": { + "description": "Runs your workflow when a label in your workflow's repository is created or modified.", + "one-of": [ + "null", + "label-mapping" + ] + }, + "label-mapping": { + "mapping": { + "properties": { + "types": "label-activity" + } + } + }, + "label-activity": { + "description": "The types of label activity that trigger the workflow. Can be one or more of: created, edited, or deleted.", + "one-of": [ + "label-activity-type", + "label-activity-types" + ] + }, + "label-activity-types": { + "sequence": { + "item-type": "label-activity-type" + } + }, + "label-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "merge-group-string": { + "description": "Runs your workflow when a pull request is added to a merge queue, which adds the pull request to a merge group.", + "string": { + "constant": "merge_group" + } + }, + "merge-group": { + "description": "Runs your workflow when a pull request is added to a merge queue, which adds the pull request to a merge group.", + "one-of": [ + "null", + "merge-group-mapping" + ] + }, + "merge-group-mapping": { + "mapping": { + "properties": { + "types": "merge-group-activity" + } + } + }, + "merge-group-activity": { + "description": "The types of label activity that trigger the workflow. Currently only checks_requested is supported.", + "one-of": [ + "merge-group-activity-type", + "merge-group-activity-types" + ] + }, + "merge-group-activity-types": { + "sequence": { + "item-type": "merge-group-activity-type" + } + }, + "merge-group-activity-type": { + "allowed-values": [ + "checks_requested" + ] + }, + "milestone-string": { + "description": "Runs your workflow when a milestone in the workflow's repository is created or modified.", + "string": { + "constant": "milestone" + } + }, + "milestone": { + "description": "Runs your workflow when a milestone in the workflow's repository is created or modified.", + "one-of": [ + "null", + "milestone-mapping" + ] + }, + "milestone-mapping": { + "mapping": { + "properties": { + "types": "milestone-activity" + } + } + }, + "milestone-activity": { + "description": "The types of milestone activity that trigger the workflow. Can be one or more of: created, edited, opened, closed, or deleted.", + "one-of": [ + "milestone-activity-type", + "milestone-activity-types" + ] + }, + "milestone-activity-types": { + "sequence": { + "item-type": "milestone-activity-type" + } + }, + "milestone-activity-type": { + "allowed-values": [ + "created", + "closed", + "opened", + "edited", + "deleted" + ] + }, + "page-build-string": { + "description": "Runs your workflow when someone pushes to a branch that is the publishing source for GitHub Pages, if GitHub Pages is enabled for the repository.", + "string": { + "constant": "page_build" + } + }, + "page-build": { + "description": "Runs your workflow when someone pushes to a branch that is the publishing source for GitHub Pages, if GitHub Pages is enabled for the repository.", + "null": {} + }, + "project-string": { + "description": "Runs your workflow when a project board is created or modified. For activity related to cards or columns in a project board, use the project_card or project_column events instead.", + "string": { + "constant": "project" + } + }, + "project": { + "description": "Runs your workflow when a project board is created or modified. For activity related to cards or columns in a project board, use the project_card or project_column events instead.", + "one-of": [ + "null", + "project-mapping" + ] + }, + "project-mapping": { + "mapping": { + "properties": { + "types": "project-activity" + } + } + }, + "project-activity": { + "description": "The types of project activity that trigger the workflow. Can be one or more of: created, edited, opened, closed, or deleted.", + "one-of": [ + "project-activity-type", + "project-activity-types" + ] + }, + "project-activity-types": { + "sequence": { + "item-type": "project-activity-type" + } + }, + "project-activity-type": { + "allowed-values": [ + "created", + "closed", + "opened", + "edited", + "deleted" + ] + }, + "project-card-string": { + "description": "Runs your workflow when a card on a project board is created or modified. For activity related to project boards or columns in a project board, use the project or project_column event instead.", + "string": { + "constant": "project_card" + } + }, + "project-card": { + "description": "Runs your workflow when a card on a project board is created or modified. For activity related to project boards or columns in a project board, use the project or project_column event instead.", + "one-of": [ + "null", + "project-card-mapping" + ] + }, + "project-card-mapping": { + "mapping": { + "properties": { + "types": "project-card-activity" + } + } + }, + "project-card-activity": { + "description": "The types of project card activity that trigger the workflow. Can be one or more of: created, edited, moved, converted, or deleted.", + "one-of": [ + "project-card-activity-type", + "project-card-activity-types" + ] + }, + "project-card-activity-types": { + "sequence": { + "item-type": "project-card-activity-type" + } + }, + "project-card-activity-type": { + "allowed-values": [ + "created", + "moved", + "converted", + "edited", + "deleted" + ] + }, + "project-column-string": { + "description": "Runs your workflow when a column on a project board is created or modified. For activity related to project boards or cards in a project board, use the project or project_card event instead.", + "string": { + "constant": "project_column" + } + }, + "project-column": { + "description": "Runs your workflow when a column on a project board is created or modified. For activity related to project boards or cards in a project board, use the project or project_card event instead.", + "one-of": [ + "null", + "project-column-mapping" + ] + }, + "project-column-mapping": { + "mapping": { + "properties": { + "types": "project-column-activity" + } + } + }, + "project-column-activity": { + "description": "The types of project column activity that trigger the workflow. Can be one or more of: created, updated, moved, or deleted.", + "one-of": [ + "project-column-activity-type", + "project-column-activity-types" + ] + }, + "project-column-activity-types": { + "sequence": { + "item-type": "project-column-activity-type" + } + }, + "project-column-activity-type": { + "allowed-values": [ + "created", + "updated", + "moved", + "deleted" + ] + }, + "public-string": { + "description": "Runs your workflow when your workflow's repository changes from private to public.", + "string": { + "constant": "public" + } + }, + "public": { + "description": "Runs your workflow when your workflow's repository changes from private to public.", + "null": {} + }, + "pull-request-string": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. For example, if no activity types are specified, the workflow runs when a pull request is opened or reopened or when the head branch of the pull request is updated. For activity related to pull request reviews, pull request review comments, or pull request comments, use the pull_request_review, pull_request_review_comment, or issue_comment events instead.", + "string": { + "constant": "pull_request" + } + }, + "pull-request": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. For example, if no activity types are specified, the workflow runs when a pull request is opened or reopened or when the head branch of the pull request is updated. For activity related to pull request reviews, pull request review comments, or pull request comments, use the pull_request_review, pull_request_review_comment, or issue_comment events instead.", + "one-of": [ + "null", + "pull-request-mapping" + ] + }, + "pull-request-mapping": { + "mapping": { + "properties": { + "types": "pull-request-activity", + "branches": "event-branches", + "branches-ignore": "event-branches-ignore", + "paths": "event-paths", + "paths-ignore": "event-paths-ignore" + } + } + }, + "pull-request-activity": { + "description": "The types of pull request activity that trigger the workflow. Can be one or more of: created, edited, opened, closed, or deleted.", + "one-of": [ + "pull-request-activity-type", + "pull-request-activity-types" + ] + }, + "pull-request-activity-types": { + "sequence": { + "item-type": "pull-request-activity-type" + } + }, + "pull-request-activity-type": { + "allowed-values": [ + "assigned", + "unassigned", + "labeled", + "unlabeled", + "opened", + "edited", + "closed", + "reopened", + "synchronize", + "converted_to_draft", + "ready_for_review", + "locked", + "unlocked", + "review_requested", + "review_request_removed", + "auto_merge_enabled", + "auto_merge_disabled" + ] + }, + "pull-request-comment-string": { + "description": "Please use the issue_comment event instead.", + "string": { + "constant": "pull_request_comment" + } + }, + "pull-request-comment": { + "description": "Please use the issue_comment event instead.", + "one-of": [ + "null", + "issue-comment-mapping" + ] + }, + "pull-request-review-string": { + "description": "Runs your workflow when a pull request review is submitted, edited, or dismissed. A pull request review is a group of pull request review comments in addition to a body comment and a state. For activity related to pull request review comments or pull request comments, use the pull_request_review_comment or issue_comment events instead.", + "string": { + "constant": "pull_request_review" + } + }, + "pull-request-review": { + "description": "Runs your workflow when a pull request review is submitted, edited, or dismissed. A pull request review is a group of pull request review comments in addition to a body comment and a state. For activity related to pull request review comments or pull request comments, use the pull_request_review_comment or issue_comment events instead.", + "one-of": [ + "null", + "pull-request-review-mapping" + ] + }, + "pull-request-review-mapping": { + "mapping": { + "properties": { + "types": "pull-request-review-activity" + } + } + }, + "pull-request-review-activity": { + "description": "The types of pull request review activity that trigger the workflow. Can be one or more of: submitted, edited, or dismissed.", + "one-of": [ + "pull-request-review-activity-type", + "pull-request-review-activity-types" + ] + }, + "pull-request-review-activity-types": { + "sequence": { + "item-type": "pull-request-review-activity-type" + } + }, + "pull-request-review-activity-type": { + "allowed-values": [ + "submitted", + "edited", + "dismissed" + ] + }, + "pull-request-review-comment-string": { + "description": "", + "string": { + "constant": "pull_request_review_comment" + } + }, + "pull-request-review-comment": { + "description": "", + "one-of": [ + "null", + "pull-request-review-comment-mapping" + ] + }, + "pull-request-review-comment-mapping": { + "mapping": { + "properties": { + "types": "pull-request-review-comment-activity" + } + } + }, + "pull-request-review-comment-activity": { + "description": "The types of pull request review comment activity that trigger the workflow. Can be one or more of: created, edited, or deleted.", + "one-of": [ + "pull-request-review-comment-activity-type", + "pull-request-review-comment-activity-types" + ] + }, + "pull-request-review-comment-activity-types": { + "sequence": { + "item-type": "pull-request-review-comment-activity-type" + } + }, + "pull-request-review-comment-activity-type": { + "allowed-values": [ + "created", + "edited", + "deleted" + ] + }, + "pull-request-target-string": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. For example, if no activity types are specified, the workflow runs when a pull request is opened or reopened or when the head branch of the pull request is updated. This event runs in the context of the base of the pull request, rather than in the context of the merge commit, as the pull_request event does. This prevents execution of unsafe code from the head of the pull request that could alter your repository or steal any secrets you use in your workflow. This event allows your workflow to do things like label or comment on pull requests from forks. Avoid using this event if you need to build or run code from the pull request.", + "string": { + "constant": "pull_request_target" + } + }, + "pull-request-target": { + "description": "Runs your workflow when activity on a pull request in the workflow's repository occurs. For example, if no activity types are specified, the workflow runs when a pull request is opened or reopened or when the head branch of the pull request is updated. This event runs in the context of the base of the pull request, rather than in the context of the merge commit, as the pull_request event does. This prevents execution of unsafe code from the head of the pull request that could alter your repository or steal any secrets you use in your workflow. This event allows your workflow to do things like label or comment on pull requests from forks. Avoid using this event if you need to build or run code from the pull request.", + "one-of": [ + "null", + "pull-request-target-mapping" + ] + }, + "pull-request-target-mapping": { + "mapping": { + "properties": { + "types": "pull-request-target-activity", + "branches": "event-branches", + "branches-ignore": "event-branches-ignore", + "paths": "event-paths", + "paths-ignore": "event-paths-ignore" + } + } + }, + "pull-request-target-activity": { + "description": "The types of pull request activity that trigger the workflow. Can be one or more of: assigned, unassigned, labeled, unlabeled, opened, edited, closed, reopened, synchronize, converted_to_draft, ready_for_review, locked, unlocked, review_requested, review_request_removed, auto_merge_enabled, or auto_merge_disabled.", + "one-of": [ + "pull-request-target-activity-type", + "pull-request-target-activity-types" + ] + }, + "pull-request-target-activity-types": { + "sequence": { + "item-type": "pull-request-target-activity-type" + } + }, + "pull-request-target-activity-type": { + "allowed-values": [ + "assigned", + "unassigned", + "labeled", + "unlabeled", + "opened", + "edited", + "closed", + "reopened", + "synchronize", + "converted_to_draft", + "ready_for_review", + "locked", + "unlocked", + "review_requested", + "review_request_removed", + "auto_merge_enabled", + "auto_merge_disabled" + ] + }, + "push-string": { + "description": "Runs your workflow when you push a commit or tag.", + "string": { + "constant": "push" + } + }, + "push": { + "description": "Runs your workflow when you push a commit or tag.", + "one-of": [ + "null", + "push-mapping" + ] + }, + "push-mapping": { + "mapping": { + "properties": { + "branches": "event-branches", + "branches-ignore": "event-branches-ignore", + "tags": "event-tags", + "tags-ignore": "event-tags-ignore", + "paths": "event-paths", + "paths-ignore": "event-paths-ignore" + } + } + }, + "registry-package-string": { + "description": "Runs your workflow when activity related to GitHub Packages occurs in your repository.", + "string": { + "constant": "registry_package" + } + }, + "registry-package": { + "description": "Runs your workflow when activity related to GitHub Packages occurs in your repository.", + "one-of": [ + "null", + "registry-package-mapping" + ] + }, + "registry-package-mapping": { + "mapping": { + "properties": { + "types": "registry-package-activity" + } + } + }, + "registry-package-activity": { + "description": "The types of registry package activity that trigger the workflow. Can be one or more of: published or updated.", + "one-of": [ + "registry-package-activity-type", + "registry-package-activity-types" + ] + }, + "registry-package-activity-types": { + "sequence": { + "item-type": "registry-package-activity-type" + } + }, + "registry-package-activity-type": { + "allowed-values": [ + "published", + "updated" + ] + }, + "release-string": { + "description": "Runs your workflow when release activity in your repository occurs.", + "string": { + "constant": "release" + } + }, + "release": { + "description": "Runs your workflow when release activity in your repository occurs.", + "one-of": [ + "null", + "release-mapping" + ] + }, + "release-mapping": { + "mapping": { + "properties": { + "types": "release-activity" + } + } + }, + "release-activity": { + "description": "The types of release activity that trigger the workflow. Can be one or more of: published, unpublished, created, edited, deleted, prereleased, or released.", + "one-of": [ + "release-activity-type", + "release-activity-types" + ] + }, + "release-activity-types": { + "sequence": { + "item-type": "release-activity-type" + } + }, + "release-activity-type": { + "allowed-values": [ + "published", + "unpublished", + "created", + "edited", + "deleted", + "prereleased", + "released" + ] + }, + "schedule-string": { + "description": "The schedule event allows you to trigger a workflow at a scheduled time. You can schedule a workflow to run at specific UTC times using POSIX cron syntax. Scheduled workflows run on the latest commit on the default or base branch. The shortest interval you can run scheduled workflows is once every 5 minutes. GitHub Actions does not support the non-standard syntax @yearly, @monthly, @weekly, @daily, @hourly, and @reboot.", + "string": { + "constant": "schedule" + } + }, + "schedule": { + "description": "The schedule event allows you to trigger a workflow at a scheduled time. You can schedule a workflow to run at specific UTC times using POSIX cron syntax. Scheduled workflows run on the latest commit on the default or base branch. The shortest interval you can run scheduled workflows is once every 5 minutes. GitHub Actions does not support the non-standard syntax @yearly, @monthly, @weekly, @daily, @hourly, and @reboot.", + "sequence": { + "item-type": "cron-mapping" + } + }, + "status-string": { + "description": "Runs your workflow when the status of a Git commit changes. For example, commits can be marked as error, failure, pending, or success. If you want to provide more details about the status change, you may want to use the check_run event.", + "string": { + "constant": "status" + } + }, + "status": { + "description": "Runs your workflow when the status of a Git commit changes. For example, commits can be marked as error, failure, pending, or success. If you want to provide more details about the status change, you may want to use the check_run event.", + "null": {} + }, + "watch-string": { + "description": "Runs your workflow when the workflow's repository is starred.", + "string": { + "constant": "watch" + } + }, + "watch": { + "description": "Runs your workflow when the workflow's repository is starred.", + "one-of": [ + "null", + "watch-mapping" + ] + }, + "watch-mapping": { + "mapping": { + "properties": { + "types": "watch-activity" + } + } + }, + "watch-activity": { + "description": "The types of watch activity that trigger the workflow. Currently, the only supported type is started.", + "one-of": [ + "watch-activity-type", + "watch-activity-types" + ] + }, + "watch-activity-types": { + "sequence": { + "item-type": "watch-activity-type" + } + }, + "watch-activity-type": { + "allowed-values": [ + "started" + ] + }, + "workflow-run-string": { + "description": "This event occurs when a workflow run is requested or completed. It allows you to execute a workflow based on execution or completion of another workflow. The workflow started by the workflow_run event is able to access secrets and write tokens, even if the previous workflow was not. This is useful in cases where the previous workflow is intentionally not privileged, but you need to take a privileged action in a later workflow.", + "string": { + "constant": "workflow_run" + } + }, + "workflow-run": { + "description": "This event occurs when a workflow run is requested or completed. It allows you to execute a workflow based on execution or completion of another workflow. The workflow started by the workflow_run event is able to access secrets and write tokens, even if the previous workflow was not. This is useful in cases where the previous workflow is intentionally not privileged, but you need to take a privileged action in a later workflow.", + "one-of": [ + "null", + "workflow-run-mapping" + ] + }, + "workflow-run-mapping": { + "mapping": { + "properties": { + "types": "workflow-run-activity", + "workflows": "workflow-run-workflows", + "branches": "event-branches", + "branches-ignore": "event-branches-ignore" + } + } + }, + "workflow-run-workflows": { + "description": "The name of the workflow that triggers the workflow_run event. The workflow must be in the same repository as the workflow that uses the workflow_run event.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "workflow-run-activity": { + "description": "The types of workflow run activity that trigger the workflow. Can be one or more of: requested or completed.", + "one-of": [ + "workflow-run-activity-type", + "workflow-run-activity-types" + ] + }, + "workflow-run-activity-types": { + "sequence": { + "item-type": "workflow-run-activity-type" + } + }, + "workflow-run-activity-type": { + "allowed-values": [ + "requested", + "completed", + "in_progress" + ] + }, + "event-branches": { + "description": "Use the branches filter to configure your workflow to only run when an event occurs on specific branches. If you use the branches filter and other filters (such as branches-ignore), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-branches-ignore": { + "description": "You can use the branches-ignore filter to configure your workflow to not run when an event occurs on specific branches. If you use the branches-ignore filter and other filters (such as branches), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-tags": { + "description": "Use the tags filter to configure your workflow to only run when an event occurs on specific tags. If you use the tags filter and other filters (such as tags-ignore), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-tags-ignore": { + "description": "You can use the tags-ignore filter to configure your workflow to not run when an event occurs on specific tags. If you use the tags-ignore filter and other filters (such as tags), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-paths": { + "description": "Configure your workflow to only run when an event changes specific files. If you use both the paths filter and other filters (such as paths-ignore), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "event-paths-ignore": { + "description": "You can use the paths-ignore filter to configure your workflow to not run when an event changes specific files. If you use both the paths-ignore filter and other filters (such as paths), the workflow will only run when all filters are satisfied.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "repository-dispatch-string": { + "description": "You can use the GitHub API to trigger a webhook event called repository_dispatch when you want to trigger a workflow for activity that happens outside of GitHub. For more information, see https://developer.github.com/v3/repos/#create-a-repository-dispatch-event. To trigger the custom repository_dispatch webhook event, you must send a POST request to a GitHub API endpoint and provide an event_type name to describe the activity type. To trigger a workflow run, you must also configure your workflow to use the repository_dispatch event.", + "string": { + "constant": "branch_protection_rule" + } + }, + "repository-dispatch": { + "description": "You can use the GitHub API to trigger a webhook event called repository_dispatch when you want to trigger a workflow for activity that happens outside of GitHub. For more information, see https://developer.github.com/v3/repos/#create-a-repository-dispatch-event. To trigger the custom repository_dispatch webhook event, you must send a POST request to a GitHub API endpoint and provide an event_type name to describe the activity type. To trigger a workflow run, you must also configure your workflow to use the repository_dispatch event.", + "one-of": [ + "null", + "repository-dispatch-mapping" + ] + }, + "repository-dispatch-mapping": { + "mapping": { + "properties": { + "types": "sequence-of-non-empty-string" + } + } + }, + "workflow-call-string": { + "description": "Allows workflows to be reused by other workflows.", + "string": { + "constant": "workflow_call" + } + }, + "workflow-call": { + "description": "Allows workflows to be reused by other workflows.", + "one-of": [ + "null", + "workflow-call-mapping" + ] + }, + "workflow-call-mapping": { + "mapping": { + "properties": { + "inputs": "workflow-call-inputs", + "secrets": "workflow-call-secrets", + "outputs": "workflow-call-outputs" + } + } + }, + "workflow-call-inputs": { + "description": "When using the workflow_call keyword, you can optionally specify inputs that are passed to the called workflow from the caller workflow.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "workflow-call-input-definition" + } + }, + "workflow-call-input-definition": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the input parameter." + }, + "type": { + "type": "workflow-call-input-type", + "required": true + }, + "required": { + "type": "boolean", + "description": "A boolean to indicate whether the action requires the input parameter. Set to true when the parameter is required." + }, + "default": "workflow-call-input-default" + } + } + }, + "workflow-call-input-type": { + "description": "Required if input is defined for the on.workflow_call keyword. The value of this parameter is a string specifying the data type of the input. This must be one of: boolean, number, or string.", + "one-of": [ + "input-type-string", + "input-type-boolean", + "input-type-number" + ] + }, + "input-type-string": { + "string": { + "constant": "string" + } + }, + "input-type-boolean": { + "string": { + "constant": "boolean" + } + }, + "input-type-number": { + "string": { + "constant": "number" + } + }, + "input-type-choice": { + "string": { + "constant": "choice" + } + }, + "input-type-environment": { + "string": { + "constant": "environment" + } + }, + "workflow-call-input-default": { + "description": "The default value is used when an input parameter isn't specified in a workflow file.", + "context": [ + "github", + "inputs", + "vars" + ], + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "workflow-call-secrets": { + "description": "A map of the secrets that can be used in the called workflow. Within the called workflow, you can use the secrets context to refer to a secret.", + "mapping": { + "loose-key-type": "workflow-call-secret-name", + "loose-value-type": "workflow-call-secret-definition" + } + }, + "workflow-call-secret-name": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the secret." + }, + "workflow-call-secret-definition": { + "one-of": [ + "null", + "workflow-call-secret-mapping-definition" + ] + }, + "workflow-call-secret-mapping-definition": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the secret parameter." + }, + "required": { + "type": "boolean", + "description": "A boolean specifying whether the secret must be supplied." + } + } + } + }, + "workflow-call-outputs": { + "description": "When using the workflow_call keyword, you can optionally specify outputs that are provided by the called workflow to the caller workflow.", + "mapping": { + "loose-key-type": "workflow-call-output-name", + "loose-value-type": "workflow-call-output-definition" + } + }, + "workflow-call-output-name": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the output. The value of is a map of the input's metadata. The must be a unique identifier within the outputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _." + }, + "workflow-call-output-definition": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the output parameter." + }, + "value": { + "type": "workflow-output-context", + "required": true + } + } + } + }, + "workflow-output-context": { + "description": "The value to assign to the output parameter.", + "context": [ + "github", + "inputs", + "vars", + "jobs" + ], + "string": {} + }, + "workflow-dispatch-string": { + "description": "You can now create workflows that are manually triggered with the new workflow_dispatch event. You will then see a 'Run workflow' button on the Actions tab, enabling you to easily trigger a run.", + "string": { + "constant": "workflow_dispatch" + } + }, + "workflow-dispatch": { + "description": "You can now create workflows that are manually triggered with the new workflow_dispatch event. You will then see a 'Run workflow' button on the Actions tab, enabling you to easily trigger a run.", + "one-of": [ + "null", + "workflow-dispatch-mapping" + ] + }, + "workflow-dispatch-mapping": { + "mapping": { + "properties": { + "inputs": "workflow-dispatch-inputs" + } + } + }, + "workflow-dispatch-inputs": { + "description": "Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.", + "mapping": { + "loose-key-type": "workflow-dispatch-input-name", + "loose-value-type": "workflow-dispatch-input" + } + }, + "workflow-dispatch-input-name": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the input. The value of is a map of the input's metadata. The must be a unique identifier within the inputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _." + }, + "workflow-dispatch-input": { + "mapping": { + "properties": { + "description": { + "type": "string", + "description": "A string description of the input parameter." + }, + "type": { + "type": "workflow-dispatch-input-type" + }, + "required": { + "type": "boolean", + "description": "A boolean to indicate whether the workflow requires the input parameter. Set to true when the parameter is required." + }, + "default": "workflow-dispatch-input-default", + "options": { + "type": "sequence-of-non-empty-string", + "description": "The options of the dropdown list, if the type is a choice." + } + } + } + }, + "workflow-dispatch-input-type": { + "description": "A string representing the type of the input. This must be one of: boolean, number, string, choice, or environment.", + "one-of": [ + "input-type-string", + "input-type-boolean", + "input-type-number", + "input-type-environment", + "input-type-choice" + ] + }, + "workflow-dispatch-input-default": { + "description": "The default value is used when an input parameter isn't specified in a workflow file.", + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "permissions": { + "description": "You can modify the default permissions granted to the GITHUB_TOKEN, adding or removing access as required, so that you only allow the minimum required access.", + "one-of": [ + "permissions-mapping", + "permission-level-shorthand-read-all", + "permission-level-shorthand-write-all" + ] + }, + "permissions-mapping": { + "mapping": { + "properties": { + "actions": "permission-level-any", + "checks": "permission-level-any", + "contents": "permission-level-any", + "deployments": "permission-level-any", + "discussions": "permission-level-any", + "id-token": "permission-level-write-or-no-access", + "issues": "permission-level-any", + "packages": "permission-level-any", + "pages": "permission-level-any", + "pull-requests": "permission-level-any", + "repository-projects": "permission-level-any", + "security-events": "permission-level-any", + "statuses": "permission-level-any" + } + } + }, + "permission-level-any": { + "description": "The permission level for the GITHUB_TOKEN.", + "one-of": [ + "permission-level-read", + "permission-level-write", + "permission-level-no-access" + ] + }, + "permission-level-read-or-no-access": { + "one-of": [ + "permission-level-read", + "permission-level-no-access" + ] + }, + "permission-level-write-or-no-access": { + "one-of": [ + "permission-level-write", + "permission-level-no-access" + ] + }, + "permission-level-read": { + "description": "The permission level for the GITHUB_TOKEN. Grants write permission for the specified scope.", + "string": { + "constant": "read" + } + }, + "permission-level-write": { + "description": "The permission level for the GITHUB_TOKEN. Grants write permission for the specified scope.", + "string": { + "constant": "write" + } + }, + "permission-level-no-access": { + "description": "The permission level for the GITHUB_TOKEN. Restricts all access for the specified scope.", + "string": { + "constant": "none" + } + }, + "permission-level-shorthand-read-all": { + "description": "The permission level for the GITHUB_TOKEN. Grants read access for all scopes.", + "string": { + "constant": "read-all" + } + }, + "permission-level-shorthand-write-all": { + "description": "The permission level for the GITHUB_TOKEN. Grants write access for all scopes.", + "string": { + "constant": "write-all" + } + }, + "workflow-defaults": { + "mapping": { + "properties": { + "run": "workflow-defaults-run" + } + } + }, + "workflow-defaults-run": { + "mapping": { + "properties": { + "shell": "shell", + "working-directory": "working-directory" + } + } + }, + "workflow-env": { + "description": "To set custom environment variables, you need to specify the variables in the workflow file. You can define environment variables for a step, job, or entire workflow using the jobs..steps[*].env, jobs..env, and env keywords. For more information, see https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepsenv", + "context": [ + "github", + "inputs", + "vars", + "secrets" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "jobs": { + "description": "A workflow run is made up of one or more jobs. Jobs run in parallel by default. To run jobs sequentially, you can define dependencies on other jobs using the jobs..needs keyword. Each job runs in a fresh instance of the virtual environment specified by runs-on. You can run an unlimited number of jobs as long as you are within the workflow usage limits. For more information, see https://help.github.com/en/github/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#usage-limits.", + "mapping": { + "loose-key-type": "job-id", + "loose-value-type": "job" + } + }, + "job-id": { + "string": { + "require-non-empty": true + }, + "description": "A string identifier to associate with the job. The value of is a map of the input's metadata. The must be a unique identifier within the inputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _." + }, + "job": { + "description": "Each job must have an id to associate with the job. The key job_id is a string and its value is a map of the job's configuration data. You must replace with a string that is unique to the jobs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _.", + "one-of": [ + "job-factory", + "workflow-job" + ] + }, + "job-factory": { + "mapping": { + "properties": { + "needs": "needs", + "if": "job-if", + "strategy": "strategy", + "name": { + "type": "string-strategy-context", + "description": "The name of the job displayed on GitHub." + }, + "runs-on": { + "type": "runs-on", + "required": true + }, + "timeout-minutes": { + "type": "number-strategy-context", + "description": "The maximum number of minutes to let a workflow run before GitHub automatically cancels it. Default: 360" + }, + "cancel-timeout-minutes": "number-strategy-context", + "continue-on-error": { + "type": "boolean-strategy-context", + "description": "Prevents a workflow run from failing when a job fails. Set to true to allow a workflow run to pass when this job fails." + }, + "container": "container", + "services": "services", + "env": "job-env", + "environment": "job-environment", + "permissions": "permissions", + "concurrency": "job-concurrency", + "outputs": "job-outputs", + "defaults": "job-defaults", + "steps": "steps" + } + } + }, + "workflow-job": { + "mapping": { + "properties": { + "name": { + "type": "string-strategy-context", + "description": "The name of the job displayed on GitHub." + }, + "uses": { + "description": "The location and version of a reusable workflow file to run as a job, of the form './{path/to}/{localfile}.yml' or '{owner}/{repo}/{path}/{filename}@{ref}'. {ref} can be a SHA, a release tag, or a branch name. Using the commit SHA is the safest for stability and security.", + "type": "non-empty-string", + "required": true + }, + "with": "workflow-job-with", + "secrets": "workflow-job-secrets", + "needs": "needs", + "if": "job-if", + "permissions": "permissions", + "concurrency": "job-concurrency", + "strategy": "strategy" + } + } + }, + "workflow-job-with": { + "description": "A map of inputs that are passed to the called workflow. Any inputs that you pass must match the input specifications defined in the called workflow. Unlike 'jobs..steps[*].with', the inputs you pass with 'jobs..with' are not be available as environment variables in the called workflow. Instead, you can reference the inputs by using the inputs context.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "scalar-needs-context" + } + }, + "workflow-job-secrets": { + "description": "When a job is used to call a reusable workflow, you can use 'secrets' to provide a map of secrets that are passed to the called workflow. Any secrets that you pass must match the names defined in the called workflow.", + "one-of": [ + "workflow-job-secrets-mapping", + "workflow-job-secrets-inherit" + ] + }, + "workflow-job-secrets-mapping": { + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "scalar-needs-context-with-secrets" + } + }, + "workflow-job-secrets-inherit": { + "string": { + "constant": "inherit" + } + }, + "needs": { + "description": "Identifies any jobs that must complete successfully before this job will run. It can be a string or array of strings. If a job fails, all jobs that need it are skipped unless the jobs use a conditional statement that causes the job to continue.", + "one-of": [ + "sequence-of-non-empty-string", + "non-empty-string" + ] + }, + "job-if": { + "description": "You can use the if conditional to prevent a job from running unless a condition is met. You can use any supported context and expression to create a conditional. Expressions in an if conditional do not require the bracketed expression syntax. For more information, see https://help.github.com/en/articles/contexts-and-expression-syntax-for-github-actions.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "always(0,0)", + "failure(0,MAX)", + "cancelled(0,0)", + "success(0,MAX)" + ], + "string": { + "is-expression": true + } + }, + "job-if-result": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "always(0,0)", + "failure(0,MAX)", + "cancelled(0,0)", + "success(0,MAX)" + ], + "one-of": [ + "null", + "boolean", + "number", + "string", + "sequence", + "mapping" + ] + }, + "strategy": { + "description": "A strategy creates a build matrix for your jobs. You can define different variations of an environment to run each job in.", + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "mapping": { + "properties": { + "fail-fast": { + "type": "boolean", + "description": "When set to true, GitHub cancels all in-progress jobs if any matrix job fails. Default: true" + }, + "max-parallel": { + "type": "number", + "description": "The maximum number of jobs that can run simultaneously when using a matrix job strategy. By default, GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines." + }, + "matrix": "matrix" + } + } + }, + "matrix": { + "description": "A build matrix is a set of different configurations of the virtual environment. For example you might run a job against more than one supported version of a language, operating system, or tool. Each configuration is a copy of the job that runs and reports a status. You can specify a matrix by supplying an array for the configuration options. For example, if the GitHub virtual environment supports Node.js versions 6, 8, and 10 you could specify an array of those versions in the matrix. When you define a matrix of operating systems, you must set the required runs-on keyword to the operating system of the current job, rather than hard-coding the operating system name. To access the operating system name, you can use the matrix.os context parameter to set runs-on. For more information, see https://help.github.com/en/articles/contexts-and-expression-syntax-for-github-actions.", + "mapping": { + "properties": { + "include": { + "type": "matrix-filter", + "description": "Use include to expand existing matrix configurations or to add new configurations. The value of include is a list of objects." + }, + "exclude": { + "type": "matrix-filter", + "description": "To remove specific configurations defined in the matrix, use exclude. An excluded configuration only has to be a partial match for it to be excluded." + } + }, + "loose-key-type": "non-empty-string", + "loose-value-type": "sequence" + } + }, + "matrix-filter": { + "sequence": { + "item-type": "matrix-filter-item" + } + }, + "matrix-filter-item": { + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "any" + } + }, + "runs-on": { + "description": "The type of machine to run the job on. The machine can be either a GitHub-hosted runner, or a self-hosted runner.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string", + "runs-on-mapping" + ] + }, + "runs-on-mapping": { + "mapping": { + "properties": { + "group": { + "description": "The group from which to select a runner.", + "type": "non-empty-string" + }, + "labels": "runs-on-labels" + } + } + }, + "runs-on-labels": { + "description": "The label by which to filter for available runners.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "job-env": { + "description": "A map of environment variables that are available to all steps in the job.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "workflow-concurrency": { + "description": "Concurrency ensures that only a single workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context. You can also specify concurrency at the workflow level.", + "context": [ + "github", + "inputs", + "vars" + ], + "one-of": [ + "string", + "concurrency-mapping" + ] + }, + "job-concurrency": { + "description": "Concurrency ensures that only a single job using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context. You can also specify concurrency at the workflow level.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "non-empty-string", + "concurrency-mapping" + ] + }, + "concurrency-mapping": { + "description": "Concurrency ensures that only a single job or workflow using the same concurrency group will run at a time. A concurrency group can be any string or expression. The expression can use any context except for the secrets context. You can also specify concurrency at the workflow level.", + "mapping": { + "properties": { + "group": { + "type": "non-empty-string", + "required": true, + "description": "When a concurrent job or workflow is queued, if another job or workflow using the same concurrency group in the repository is in progress, the queued job or workflow will be pending. Any previously pending job or workflow in the concurrency group will be canceled." + }, + "cancel-in-progress": { + "type": "boolean", + "description": "To cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true." + } + } + } + }, + "job-environment": { + "description": "The environment that the job references. All environment protection rules must pass before a job referencing the environment is sent to a runner.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "job-environment-mapping" + ] + }, + "job-environment-mapping": { + "mapping": { + "properties": { + "name": { + "type": "job-environment-name", + "required": true + }, + "url": { + "type": "string-runner-context-no-secrets", + "description": "The environment URL, which maps to `environment_url` in the deployments API." + } + } + } + }, + "job-environment-name": { + "description": "The name of the environment.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "string": {} + }, + "job-defaults": { + "description": "A map of default settings that will apply to all steps in the job.", + "mapping": { + "properties": { + "run": "job-defaults-run" + } + } + }, + "job-defaults-run": { + "context": [ + "github", + "inputs", + "vars", + "strategy", + "matrix", + "needs", + "env" + ], + "mapping": { + "properties": { + "shell": "shell", + "working-directory": "working-directory" + } + } + }, + "job-outputs": { + "description": "A map of outputs for a job. Job outputs are available to all downstream jobs that depend on this job.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string-runner-context" + } + }, + "steps": { + "description": "A job contains a sequence of tasks called steps. Steps can run commands, run setup tasks, or run an action in your repository, a public repository, or an action published in a Docker registry. Not all steps run actions, but all actions run as a step. Each step runs in its own process in the virtual environment and has access to the workspace and filesystem. Because steps run in their own process, changes to environment variables are not preserved between steps. GitHub provides built-in steps to set up and complete a job. Must contain either `uses` or `run`.", + "sequence": { + "item-type": "steps-item" + } + }, + "steps-item": { + "one-of": [ + "run-step", + "regular-step" + ] + }, + "run-step": { + "mapping": { + "properties": { + "name": "step-name", + "id": "step-id", + "if": "step-if", + "timeout-minutes": "step-timeout-minutes", + "run": { + "type": "string-steps-context", + "description": "Runs command-line programs using the operating system's shell. If you do not provide a name, the step name will default to the text specified in the run command. Commands run using non-login shells by default. You can choose a different shell and customize the shell used to run commands. Each run keyword represents a new process and shell in the virtual environment. When you provide multi-line commands, each line runs in the same shell.", + "required": true + }, + "continue-on-error": "step-continue-on-error", + "env": "step-env", + "working-directory": "string-steps-context", + "shell": "shell" + } + } + }, + "regular-step": { + "mapping": { + "properties": { + "name": "step-name", + "id": "step-id", + "if": "step-if", + "continue-on-error": "step-continue-on-error", + "timeout-minutes": "step-timeout-minutes", + "uses": { + "description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image (https://hub.docker.com/).", + "type": "non-empty-string", + "required": true + }, + "with": "step-with", + "env": "step-env" + } + } + }, + "step-continue-on-error": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "boolean": {}, + "description": "Prevents a job from failing when a step fails. Set to true to allow a job to pass when this step fails." + }, + "step-id": { + "string": { + "require-non-empty": true + }, + "description": "A unique identifier for the step. You can use the id to reference the step in contexts." + }, + "step-if": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "steps", + "job", + "runner", + "env", + "always(0,0)", + "failure(0,0)", + "cancelled(0,0)", + "success(0,0)", + "hashFiles(1,255)" + ], + "description": "You can use the if conditional to prevent a step from running unless a condition is met. You can use any supported context and expression to create a conditional. Expressions in an if conditional do not require the bracketed expression syntax.", + "string": { + "is-expression": true + } + }, + "step-if-result": { + "context": [ + "github", + "inputs", + "vars", + "strategy", + "matrix", + "steps", + "job", + "runner", + "env", + "always(0,0)", + "failure(0,0)", + "cancelled(0,0)", + "success(0,0)", + "hashFiles(1,255)" + ], + "one-of": [ + "null", + "boolean", + "number", + "string", + "sequence", + "mapping" + ] + }, + "step-env": { + "description": "Sets environment variables for steps to use in the virtual environment. You can also set environment variables for the entire workflow or a job.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "step-name": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "string": {}, + "description": "A name for your step to display on GitHub." + }, + "step-timeout-minutes": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "number": {}, + "description": "The maximum number of minutes to run the step before killing the process." + }, + "step-with": { + "description": "A map of the input parameters defined by the action. Each input parameter is a key/value pair. Input parameters are set as environment variables. The variable is prefixed with INPUT_ and converted to upper case.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string" + } + }, + "container": { + "description": "A container to run any steps in a job that don't already specify a container. If you have steps that use both script and container actions, the container actions will run as sibling containers on the same network with the same volume mounts. If you do not set a container, all steps will run directly on the host specified by runs-on unless a step refers to an action configured to run in a container.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "container-mapping" + ] + }, + "container-mapping": { + "mapping": { + "properties": { + "image": { + "type": "non-empty-string", + "description": "The Docker image to use as the container to run the job. The value can be the Docker Hub image name or a registry name." + }, + "options": { + "type": "non-empty-string", + "description": "Additional Docker container resource options. For a list of options, see https://docs.docker.com/engine/reference/commandline/create/#options." + }, + "env": "container-env", + "ports": { + "type": "sequence-of-non-empty-string", + "description": "Sets an array of ports to expose on the container." + }, + "volumes": { + "type": "sequence-of-non-empty-string", + "description": "Sets an array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host.\nTo specify a volume, you specify the source and destination path: :\nThe is a volume name or an absolute path on the host machine, and is an absolute path in the container." + }, + "credentials": "container-registry-credentials" + } + } + }, + "services": { + "description": "Additional containers to host services for a job in a workflow. These are useful for creating databases or cache services like redis. The runner on the virtual machine will automatically create a network and manage the life cycle of the service containers. When you use a service container for a job or your step uses container actions, you don't need to set port information to access the service. Docker automatically exposes all ports between containers on the same network. When both the job and the action run in a container, you can directly reference the container by its hostname. The hostname is automatically mapped to the service name. When a step does not use a container action, you must access the service using localhost and bind the ports.", + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "services-container" + } + }, + "services-container": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "non-empty-string", + "container-mapping" + ] + }, + "container-registry-credentials": { + "description": "If the image's container registry requires authentication to pull the image, you can use credentials to set a map of the username and password. The credentials are the same values that you would provide to the `docker login` command.", + "context": [ + "github", + "inputs", + "vars", + "secrets", + "env" + ], + "mapping": { + "properties": { + "username": "non-empty-string", + "password": "non-empty-string" + } + } + }, + "container-env": { + "description": "Sets an array of environment variables in the container.", + "mapping": { + "loose-key-type": "non-empty-string", + "loose-value-type": "string-runner-context" + } + }, + "non-empty-string": { + "string": { + "require-non-empty": true + } + }, + "sequence-of-non-empty-string": { + "sequence": { + "item-type": "non-empty-string" + } + }, + "boolean-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "boolean": {} + }, + "number-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "number": {} + }, + "string-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs" + ], + "string": {} + }, + "scalar-needs-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "scalar-needs-context-with-secrets": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "secrets", + "strategy", + "matrix" + ], + "one-of": [ + "string", + "boolean", + "number" + ] + }, + "boolean-strategy-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "boolean": {} + }, + "number-strategy-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "number": {} + }, + "string-strategy-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix" + ], + "string": {} + }, + "boolean-steps-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "boolean": {} + }, + "number-steps-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "number": {} + }, + "string-runner-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env" + ], + "string": {} + }, + "string-runner-context-no-secrets": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "steps", + "job", + "runner", + "env" + ], + "string": {} + }, + "string-steps-context": { + "context": [ + "github", + "inputs", + "vars", + "needs", + "strategy", + "matrix", + "secrets", + "steps", + "job", + "runner", + "env", + "hashFiles(1,255)" + ], + "string": {} + }, + "shell": { + "string": { + "require-non-empty": true + }, + "description": "You can override the default shell settings in the runner's operating system using the shell keyword. You can use built-in shell keywords, or you can define a custom set of shell options." + }, + "working-directory": { + "string": { + "require-non-empty": true + }, + "description": "Using the working-directory keyword, you can specify the working directory of where to run the command." + }, + "cron-mapping": { + "mapping": { + "properties": { + "cron": "cron-pattern" + } + } + }, + "cron-pattern": { + "string": { + "require-non-empty": true + } + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index a1566f5..5f3f3c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,8 @@ { "private": true, "workspaces": [ + "actions-expressions", + "actions-workflow-parser", "actions-languageservice", "actions-languageserver", "browser-playground"