diff --git a/actions-expressions/.prettierignore b/actions-expressions/.prettierignore new file mode 100644 index 0000000..ce98533 --- /dev/null +++ b/actions-expressions/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +*.md +*.js +*.json diff --git a/actions-expressions/package.json b/actions-expressions/package.json index 57a3bbf..15c566c 100755 --- a/actions-expressions/package.json +++ b/actions-expressions/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-expressions", - "version": "0.1.113", + "version": "0.1.126", "license": "MIT", "type": "module", "source": "./src/index.ts", diff --git a/actions-expressions/src/ast.ts b/actions-expressions/src/ast.ts index 04a87b6..cb37fad 100644 --- a/actions-expressions/src/ast.ts +++ b/actions-expressions/src/ast.ts @@ -17,7 +17,7 @@ export abstract class Expr { } export class Literal extends Expr { - constructor(public literal: ExpressionData) { + constructor(public literal: ExpressionData, public token: Token) { super(); } diff --git a/actions-expressions/src/completion/descriptionDictionary.ts b/actions-expressions/src/completion/descriptionDictionary.ts index 08a4eda..0fe6892 100644 --- a/actions-expressions/src/completion/descriptionDictionary.ts +++ b/actions-expressions/src/completion/descriptionDictionary.ts @@ -34,4 +34,8 @@ export class DescriptionDictionary extends Dictionary { const pairs = super.pairs(); return pairs.map(p => ({...p, description: this.descriptions.get(p.key)})); } + + getDescription(key: string): string | undefined { + return this.descriptions.get(key); + } } diff --git a/actions-expressions/src/errors.ts b/actions-expressions/src/errors.ts index 0a2db07..72202a9 100644 --- a/actions-expressions/src/errors.ts +++ b/actions-expressions/src/errors.ts @@ -20,7 +20,7 @@ export class ExpressionError extends Error { constructor(private typ: ErrorType, private tok: Token) { super(`${errorDescription(typ)}: '${tokenString(tok)}'`); - this.pos = this.tok.pos; + this.pos = this.tok.range.start; } public pos: Pos; diff --git a/actions-expressions/src/lexer.test.ts b/actions-expressions/src/lexer.test.ts index 749f721..3145534 100644 --- a/actions-expressions/src/lexer.test.ts +++ b/actions-expressions/src/lexer.test.ts @@ -3,32 +3,32 @@ import {Lexer, Token, TokenType} from "./lexer"; describe("lexer", () => { const tests: { input: string; - tokenType: TokenType[]; - token?: Token; + tokenTypes: TokenType[]; + tokens?: Token[]; }[] = [ - {input: "<", tokenType: [TokenType.LESS]}, - {input: ">", tokenType: [TokenType.GREATER]}, + {input: "<", tokenTypes: [TokenType.LESS]}, + {input: ">", tokenTypes: [TokenType.GREATER]}, - {input: "!=", tokenType: [TokenType.BANG_EQUAL]}, - {input: "==", tokenType: [TokenType.EQUAL_EQUAL]}, - {input: "<=", tokenType: [TokenType.LESS_EQUAL]}, - {input: ">=", tokenType: [TokenType.GREATER_EQUAL]}, + {input: "!=", tokenTypes: [TokenType.BANG_EQUAL]}, + {input: "==", tokenTypes: [TokenType.EQUAL_EQUAL]}, + {input: "<=", tokenTypes: [TokenType.LESS_EQUAL]}, + {input: ">=", tokenTypes: [TokenType.GREATER_EQUAL]}, - {input: "&&", tokenType: [TokenType.AND]}, - {input: "||", tokenType: [TokenType.OR]}, + {input: "&&", tokenTypes: [TokenType.AND]}, + {input: "||", tokenTypes: [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]}, + {input: "12", tokenTypes: [TokenType.NUMBER]}, + {input: "12.0", tokenTypes: [TokenType.NUMBER]}, + {input: "0", tokenTypes: [TokenType.NUMBER]}, + {input: "-0", tokenTypes: [TokenType.NUMBER]}, + {input: "-12.0", tokenTypes: [TokenType.NUMBER]}, // Strings - {input: "'It''s okay'", tokenType: [TokenType.STRING]}, + {input: "'It''s okay'", tokenTypes: [TokenType.STRING]}, { input: "format('{0} == ''queued''', needs)", - tokenType: [ + tokenTypes: [ TokenType.IDENTIFIER, TokenType.LEFT_PAREN, TokenType.STRING, @@ -41,87 +41,161 @@ describe("lexer", () => { // Arrays { input: "[1,2]", - tokenType: [TokenType.LEFT_BRACKET, TokenType.NUMBER, TokenType.COMMA, TokenType.NUMBER, TokenType.RIGHT_BRACKET] + tokenTypes: [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] + tokenTypes: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER] }, { input: "1== 1", - tokenType: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER] + tokenTypes: [TokenType.NUMBER, TokenType.EQUAL_EQUAL, TokenType.NUMBER] }, { input: "1< 1", - tokenType: [TokenType.NUMBER, TokenType.LESS, TokenType.NUMBER] + tokenTypes: [TokenType.NUMBER, TokenType.LESS, TokenType.NUMBER] }, // Identifiers { input: "github", - tokenType: [TokenType.IDENTIFIER], - token: { - type: TokenType.IDENTIFIER, - lexeme: "github", - pos: { - line: 0, - column: 0 + tokenTypes: [TokenType.IDENTIFIER], + tokens: [ + { + type: TokenType.IDENTIFIER, + lexeme: "github", + range: { + start: { + line: 0, + column: 0 + }, + end: { + line: 0, + column: 6 + } + } } - } + ] }, // Keywords { input: "true", - tokenType: [TokenType.TRUE], - token: { - type: TokenType.TRUE, - lexeme: "true", - pos: { - line: 0, - column: 0 + tokenTypes: [TokenType.TRUE], + tokens: [ + { + type: TokenType.TRUE, + lexeme: "true", + range: { + start: { + line: 0, + column: 0 + }, + end: { + line: 0, + column: 4 + } + } } - } + ] }, { input: "false", - tokenType: [TokenType.FALSE], - token: { - type: TokenType.FALSE, - lexeme: "false", - pos: { - line: 0, - column: 0 + tokenTypes: [TokenType.FALSE], + tokens: [ + { + type: TokenType.FALSE, + lexeme: "false", + range: { + start: { + line: 0, + column: 0 + }, + end: { + line: 0, + column: 5 + } + } } - } + ] }, { input: "null", - tokenType: [TokenType.NULL], - token: { - type: TokenType.NULL, - lexeme: "null", - pos: { - line: 0, - column: 0 + tokenTypes: [TokenType.NULL], + tokens: [ + { + type: TokenType.NULL, + lexeme: "null", + range: { + start: { + line: 0, + column: 0 + }, + end: { + line: 0, + column: 4 + } + } } - } + ] + }, + + { + input: "github\n ==", + tokenTypes: [TokenType.IDENTIFIER, TokenType.EQUAL_EQUAL], + tokens: [ + { + type: TokenType.IDENTIFIER, + lexeme: "github", + range: { + start: { + line: 0, + column: 0 + }, + end: { + line: 0, + column: 6 + } + } + }, + { + type: TokenType.EQUAL_EQUAL, + lexeme: "==", + range: { + start: { + line: 1, + column: 1 + }, + end: { + line: 1, + column: 3 + } + } + } + ] } ]; - test.each(tests)("$input", ({input, tokenType, token}: {input: string; tokenType: TokenType[]; token?: Token}) => { - const l = new Lexer(input); + test.each(tests)( + "$input", + ({input, tokenTypes, tokens}: {input: string; tokenTypes: TokenType[]; tokens?: Token[]}) => { + const l = new Lexer(input); - const r = l.lex(); + const r = l.lex(); - const want = r.tokens.map(t => t.type); + const got = r.tokens.map(t => t.type); - tokenType.push(TokenType.EOF); + tokenTypes.push(TokenType.EOF); + expect(got).toEqual(tokenTypes); - expect(want).toEqual(tokenType); - }); + if (tokens) { + // Ignore the last EOF token + expect(r.tokens.slice(0, r.tokens.length - 1)).toEqual(tokens); + } + } + ); }); diff --git a/actions-expressions/src/lexer.ts b/actions-expressions/src/lexer.ts index 2d9c0fa..c52bbf2 100644 --- a/actions-expressions/src/lexer.ts +++ b/actions-expressions/src/lexer.ts @@ -36,13 +36,18 @@ export type Pos = { column: number; }; +export type Range = { + start: Pos; + end: Pos; +}; + export type Token = { type: TokenType; lexeme: string; value?: string | number | boolean; - pos: Pos; + range: Range; }; export function tokenString(tok: Token): string { @@ -192,7 +197,7 @@ export class Lexer { this.tokens.push({ type: TokenType.EOF, lexeme: "", - pos: this.pos() + range: this.range() }); return { @@ -207,6 +212,20 @@ export class Lexer { }; } + private endPos(): Pos { + return { + line: this.line, + column: this.offset - this.lastLineOffset + }; + } + + private range(): Range { + return { + start: this.pos(), + end: this.endPos() + }; + } + private atEnd(): boolean { return this.offset >= this.input.length; } @@ -240,10 +259,6 @@ export class Lexer { return this.input[this.offset++]; } - private reverse(): string { - return this.input[--this.offset]; - } - private match(expected: string): boolean { if (this.atEnd()) { return false; @@ -260,7 +275,7 @@ export class Lexer { this.tokens.push({ type, lexeme: this.input.substring(this.start, this.offset), - pos: this.pos(), + range: this.range(), value }); } diff --git a/actions-expressions/src/parser.ts b/actions-expressions/src/parser.ts index ee4cf3a..9345bd6 100644 --- a/actions-expressions/src/parser.ts +++ b/actions-expressions/src/parser.ts @@ -196,7 +196,7 @@ export class Parser { if (this.match(TokenType.IDENTIFIER)) { let property = this.previous(); - expr = new IndexAccess(expr, new Literal(new data.StringData(property.lexeme))); + expr = new IndexAccess(expr, new Literal(new data.StringData(property.lexeme), property)); } else if (this.match(TokenType.STAR)) { expr = new IndexAccess(expr, new Star()); } else { @@ -254,19 +254,19 @@ export class Parser { private primary(): Expr { switch (true) { case this.match(TokenType.FALSE): - return new Literal(new data.BooleanData(false)); + return new Literal(new data.BooleanData(false), this.previous()); case this.match(TokenType.TRUE): - return new Literal(new data.BooleanData(true)); + return new Literal(new data.BooleanData(true), this.previous()); case this.match(TokenType.NULL): - return new Literal(new data.Null()); + return new Literal(new data.Null(), this.previous()); case this.match(TokenType.NUMBER): - return new Literal(new data.NumberData(this.previous().value as number)); + return new Literal(new data.NumberData(this.previous().value as number), this.previous()); case this.match(TokenType.STRING): - return new Literal(new data.StringData(this.previous().value as string)); + return new Literal(new data.StringData(this.previous().value as string), this.previous()); case this.match(TokenType.LEFT_PAREN): const expr = this.expression(); diff --git a/actions-languageserver/.prettierignore b/actions-languageserver/.prettierignore new file mode 100644 index 0000000..ce98533 --- /dev/null +++ b/actions-languageserver/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +*.md +*.js +*.json diff --git a/actions-languageserver/package.json b/actions-languageserver/package.json index 3aed71f..3b355cf 100644 --- a/actions-languageserver/package.json +++ b/actions-languageserver/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-languageserver", - "version": "0.1.113", + "version": "0.1.126", "description": "Language server for GitHub Actions", "license": "MIT", "type": "module", @@ -38,8 +38,8 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@github/actions-languageservice": "^0.1.113", - "@github/actions-workflow-parser": "^0.1.113", + "@github/actions-languageservice": "^0.1.126", + "@github/actions-workflow-parser": "^0.1.126", "@octokit/rest": "^19.0.7", "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.7", diff --git a/actions-languageserver/src/connection.ts b/actions-languageserver/src/connection.ts index daddd1c..d061b4e 100644 --- a/actions-languageserver/src/connection.ts +++ b/actions-languageserver/src/connection.ts @@ -20,6 +20,7 @@ import {getClient} from "./client"; import {Commands} from "./commands"; import {contextProviders} from "./context-providers"; import {descriptionProvider} from "./description-provider"; +import {getFileProvider} from "./file-provider"; import {InitializationOptions, RepositoryContext} from "./initializationOptions"; import {onCompletion} from "./on-completion"; import {TTLCache} from "./utils/cache"; @@ -106,7 +107,10 @@ export function initConnection(connection: Connection) { } return undefined; - } + }, + fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => { + return await connection.sendRequest("actions/readFile", {path}); + }) }; const result = await validate(textDocument, config); @@ -124,8 +128,11 @@ export function initConnection(connection: Connection) { }); connection.onHover(async ({position, textDocument}: HoverParams): Promise => { + const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)); + return hover(documents.get(textDocument.uri)!, position, { - descriptionProvider: descriptionProvider(client, cache) + descriptionProvider: descriptionProvider(client, cache), + contextProviderConfig: repoContext && contextProviders(client, repoContext, cache) }); }); diff --git a/actions-languageserver/src/context-providers/steps.test.ts b/actions-languageserver/src/context-providers/steps.test.ts index 0120216..019a612 100644 --- a/actions-languageserver/src/context-providers/steps.test.ts +++ b/actions-languageserver/src/context-providers/steps.test.ts @@ -56,7 +56,7 @@ const actionMetadata = { }; it("returns default context when job is undefined", async () => { - const workflowContext = createWorkflowContext(workflow, undefined); + const workflowContext = await createWorkflowContext(workflow, undefined); const defaultContext = getDefaultStepsContext(workflowContext); const stepsContext = await getStepsContext(new Octokit(), new TTLCache(), defaultContext, workflowContext); @@ -68,7 +68,7 @@ it("adds action outputs", async () => { .sandbox() .getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata); - const workflowContext = createWorkflowContext(workflow, "build"); + const workflowContext = await createWorkflowContext(workflow, "build"); const defaultContext = getDefaultStepsContext(workflowContext); const stepsContext = await getStepsContext( diff --git a/actions-languageserver/src/description-providers/action-input.test.ts b/actions-languageserver/src/description-providers/action-input.test.ts index d1bd0eb..9912982 100644 --- a/actions-languageserver/src/description-providers/action-input.test.ts +++ b/actions-languageserver/src/description-providers/action-input.test.ts @@ -60,7 +60,7 @@ const actionMetadata = { }; async function getDescription(input: string, mock: fetchMock.FetchMockSandbox) { - const workflowContext = createWorkflowContext(workflow, "build", 0); + const workflowContext = await createWorkflowContext(workflow, "build", 0); return await getActionInputDescription( new Octokit({ diff --git a/actions-languageserver/src/file-provider.ts b/actions-languageserver/src/file-provider.ts new file mode 100644 index 0000000..398dd3c --- /dev/null +++ b/actions-languageserver/src/file-provider.ts @@ -0,0 +1,75 @@ +import {File} from "@github/actions-workflow-parser/workflows/file"; +import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider"; +import {fileIdentifier} from "@github/actions-workflow-parser/workflows/file-reference"; +import {Octokit} from "@octokit/rest"; +import path from "path"; +import {TTLCache} from "./utils/cache"; + +export function getFileProvider( + client: Octokit | undefined, + cache: TTLCache, + workspace: string | undefined, + readFile: (path: string) => Promise +): FileProvider | undefined { + if (!client && !workspace) { + return undefined; + } + + return { + getFileContent: async (ref): Promise => { + if ("repository" in ref) { + if (!client) { + throw new Error("Remote file references are not supported with this configuration"); + } + + return await cache.get(`file-content-${fileIdentifier(ref)}`, undefined, () => + fetchWorkflowFile(client, ref.owner, ref.repository, ref.path, ref.version) + ); + } + + if (!workspace) { + throw new Error("Local file references are not supported with this configuration"); + } + + const file = await readFile(path.join(workspace, ref.path)); + if (!file) { + throw new Error(`File not found: ${ref.path}`); + } + return { + name: ref.path, + content: file + }; + } + }; +} + +async function fetchWorkflowFile( + client: Octokit, + owner: string, + repo: string, + path: string, + version: string +): Promise { + const resp = await client.repos.getContent({ + owner, + repo, + path, + ref: version + }); + + // https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28 + // Ignore directories (array of files) and non-file content + if ( + resp.data === undefined || + Array.isArray(resp.data) || + resp.data.type !== "file" || + resp.data.content === undefined + ) { + throw new Error("Not a file"); + } + + return { + name: path, + content: Buffer.from(resp.data.content, "base64").toString("utf8") + }; +} diff --git a/actions-languageserver/src/test-utils/workflow-context.ts b/actions-languageserver/src/test-utils/workflow-context.ts index aef5cfe..edde697 100644 --- a/actions-languageserver/src/test-utils/workflow-context.ts +++ b/actions-languageserver/src/test-utils/workflow-context.ts @@ -1,5 +1,6 @@ import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context"; import {convertWorkflowTemplate, parseWorkflow, TraceWriter} from "@github/actions-workflow-parser"; +import {isJob} from "@github/actions-workflow-parser/model/type-guards"; const nullTrace: TraceWriter = { info: x => {}, @@ -7,16 +8,27 @@ const nullTrace: TraceWriter = { error: x => {} }; -export function createWorkflowContext(workflow: string, job?: string, stepIndex?: number): WorkflowContext { - const parsed = parseWorkflow("test.yaml", [{name: "test.yaml", content: workflow}], nullTrace); +export async function createWorkflowContext( + workflow: string, + job?: string, + stepIndex?: number +): Promise { + const parsed = parseWorkflow({name: "test.yaml", content: workflow}, nullTrace); if (!parsed.value) { throw new Error("Failed to parse workflow"); } - const template = convertWorkflowTemplate(parsed.context, parsed.value); + const template = await convertWorkflowTemplate(parsed.context, parsed.value); const context: WorkflowContext = {uri: "test.yaml", template}; if (job) { - context.job = template.jobs.find(j => j.id.value === job); + const workflowJob = template.jobs.find(j => j.id.value === job); + if (workflowJob) { + if (isJob(workflowJob)) { + context.job = workflowJob; + } else { + context.reusableWorkflowJob = workflowJob; + } + } } if (stepIndex !== undefined) { diff --git a/actions-languageservice/package.json b/actions-languageservice/package.json index 5097c14..0a94fd9 100644 --- a/actions-languageservice/package.json +++ b/actions-languageservice/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-languageservice", - "version": "0.1.113", + "version": "0.1.126", "description": "Language service for GitHub Actions", "license": "MIT", "type": "module", @@ -38,8 +38,8 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@github/actions-expressions": "^0.1.113", - "@github/actions-workflow-parser": "^0.1.113", + "@github/actions-expressions": "^0.1.126", + "@github/actions-workflow-parser": "^0.1.126", "vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-types": "^3.17.2", "yaml": "^2.1.1" diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts index d7f2b25..47b685c 100644 --- a/actions-languageservice/src/complete.expressions.test.ts +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -153,6 +153,24 @@ describe("expressions", () => { ]); }); + it("multiple regions - first region", async () => { + const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}"; + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + + expect(result.map(x => x.label)).toEqual([ + "github", + "inputs", + "vars", + "contains", + "endsWith", + "format", + "fromJson", + "join", + "startsWith", + "toJson" + ]); + }); + it("multiple regions", async () => { const input = "run-name: test-${{ github }}-${{ | }}"; const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index a0ce6e0..ad4004f 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -1,4 +1,4 @@ -import {DescriptionDictionary, complete as completeExpression} from "@github/actions-expressions"; +import {complete as completeExpression, DescriptionDictionary} from "@github/actions-expressions"; import {CompletionItem as ExpressionCompletionItem} from "@github/actions-expressions/completion"; import { convertWorkflowTemplate, @@ -8,8 +8,6 @@ import { parseWorkflow } from "@github/actions-workflow-parser"; import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; -import {DefinitionType} from "@github/actions-workflow-parser/templates/schema/definition-type"; -import {StringDefinition} from "@github/actions-workflow-parser/templates/schema/string-definition"; import {OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants"; import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index"; import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token"; @@ -23,14 +21,15 @@ import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; import {validatorFunctions} from "./expression-validation/functions"; import {error} from "./log"; import {nullTrace} from "./nulltrace"; +import {isPotentiallyExpression} from "./utils/expression-detection"; import {findToken} from "./utils/find-token"; import {guessIndentation} from "./utils/indentation-guesser"; import {mapRange} from "./utils/range"; +import {getRelCharOffset} from "./utils/rel-char-pos"; import {transform} from "./utils/transform"; import {Value, ValueProviderConfig} from "./value-providers/config"; import {defaultValueProviders} from "./value-providers/default"; import {definitionValues} from "./value-providers/definition"; -import {TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range"; export function getExpressionInput(input: string, pos: number): string { // Find start marker around the cursor position @@ -66,22 +65,19 @@ export async function complete( name: textDocument.uri, content: newDoc.getText() }; - const result = parseWorkflow(file.name, [file], nullTrace); + const result = parseWorkflow(file, nullTrace); if (!result.value) { return []; } const {token, keyToken, parent, path} = findToken(newPos, result.value); - const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const template = await convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); const workflowContext = getWorkflowContext(textDocument.uri, template, path); // If we are inside an expression, take a different code-path. The workflow parser does not correctly create // expression nodes for invalid expressions and during editing expressions are invalid most of the time. if (token) { - const isStringExpressionToken = isStringExpression(token); - const isBasicExpressionToken = isBasicExpression(token) && token.isExpression; - - if (isStringExpressionToken || isBasicExpressionToken) { + if (isBasicExpression(token) || isPotentiallyExpression(token)) { const allowedContext = token.definitionInfo?.allowedContext || []; const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion); @@ -93,12 +89,14 @@ export async function complete( const indentString = " ".repeat(indentation.tabSize); const values = await getValues(token, keyToken, parent, valueProviderConfig, workflowContext, indentString); + let replaceRange: Range | undefined; if (token?.range) { replaceRange = mapRange(token.range); } else if (!token) { // Not a valid token, create a range from the current position const line = newDoc.getText({start: {line: position.line, character: 0}, end: position}); + // Get the length of the current word const val = line.match(/[\w_-]*$/)?.[0].length || 0; replaceRange = Range.create({line: position.line, character: position.character - val}, position); @@ -214,12 +212,12 @@ function getExpressionCompletionItems( currentInput = stringToken.source || stringToken.value; } - const relCharPos = getRelCharPos(token.range!, currentInput, pos); - const expressionInput = (getExpressionInput(currentInput, relCharPos) || "").trim(); + const relCharOffset = getRelCharOffset(token.range!, currentInput, pos); + const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim(); try { return completeExpression(expressionInput, context, [], validatorFunctions).map(item => - mapExpressionCompletionItem(item, currentInput[relCharPos]) + mapExpressionCompletionItem(item, currentInput[relCharOffset]) ); } catch (e: any) { error(`Error while completing expression: '${e?.message || ""}'`); @@ -250,23 +248,3 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable }; } - -function getRelCharPos(tokenRange: TokenRange, currentInput: string, pos: Position): number { - // Transform the overall position into a node relative position - const range = mapRange(tokenRange); - if (range.start.line !== range.end.line) { - const lines = currentInput.split("\n"); - const lineDiff = pos.line - range.start.line - 1; - const linesBeforeCusor = lines.slice(0, lineDiff); - return linesBeforeCusor.join("\n").length + pos.character + 1; - } else { - return pos.character - range.start.character; - } -} - -function isStringExpression(token: TemplateToken): boolean { - const isExpression = - token.definition?.definitionType === DefinitionType.String && (token.definition as StringDefinition).isExpression; - const containsExpression = isString(token) && token.value.indexOf(OPEN_EXPRESSION) >= 0; - return isExpression || containsExpression; -} diff --git a/actions-languageservice/src/context-providers/descriptions.json b/actions-languageservice/src/context-providers/descriptions.json index f2b35b6..176871c 100644 --- a/actions-languageservice/src/context-providers/descriptions.json +++ b/actions-languageservice/src/context-providers/descriptions.json @@ -182,5 +182,39 @@ "outcome": { "description": "The result of a completed step before `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`." } + }, + "runner": { + "name": { + "description": "The name of the runner executing the job." + }, + "os": { + "description": "The operating system of the runner executing the job. Possible values are `Linux`, `Windows`, or `macOS`." + }, + "arch": { + "description": "The architecture of the runner executing the job. Possible values are `X86`, `X64`, `ARM`, or `ARM64`." + }, + "temp": { + "description": "The path to a temporary directory on the runner. This directory is emptied at the beginning and end of each job. Note that files will not be removed if the runner's user account does not have permission to delete them." + }, + "tool_cache": { + "description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software)\"." + }, + "debug": { + "description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of 1. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps." + } + }, + "strategy": { + "fail-fast": { + "description": "The `fail-fast` setting for the job. Possible values are `true` or `false`. For more information, see [Workflow syntax for GitHub Actions: `jobs..strategy.fail-fast`](https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast)." + }, + "max-parallel": { + "description": "The `max-parallel` setting for the job. For more information, see [Workflow syntax for GitHub Actions: `jobs..strategy.max-parallel`](https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategymax-parallel)." + }, + "job-index": { + "description": "The index of the current job in the matrix. **Note:** This number is a zero-based number. The first job's index in the matrix is `0`." + }, + "job-total": { + "description": "The total number of jobs in the matrix. **Note:** This number **is not** a zero-based number. For example, for a matrix with four jobs, the value of `job-total` is `4`." + } } } \ No newline at end of file diff --git a/actions-languageservice/src/context-providers/matrix.test.ts b/actions-languageservice/src/context-providers/matrix.test.ts index 3285b0d..65dea8c 100644 --- a/actions-languageservice/src/context-providers/matrix.test.ts +++ b/actions-languageservice/src/context-providers/matrix.test.ts @@ -45,7 +45,7 @@ function stringToToken(value: string) { } function expressionToToken(expr: string) { - return new BasicExpressionToken(undefined, undefined, expr, undefined, undefined, expr); + return new BasicExpressionToken(undefined, undefined, expr, undefined, undefined, undefined); } function contextFromStrategy(strategy?: TemplateToken) { diff --git a/actions-languageservice/src/context-providers/needs.ts b/actions-languageservice/src/context-providers/needs.ts index e6cb66d..e261b0d 100644 --- a/actions-languageservice/src/context-providers/needs.ts +++ b/actions-languageservice/src/context-providers/needs.ts @@ -1,6 +1,7 @@ import {data, DescriptionDictionary} from "@github/actions-expressions"; import {isScalar, isString} from "@github/actions-workflow-parser"; -import {Job} from "@github/actions-workflow-parser/model/workflow-template"; +import {isJob} from "@github/actions-workflow-parser/model/type-guards"; +import {Job, WorkflowJob} from "@github/actions-workflow-parser/model/workflow-template"; import {WorkflowContext} from "../context/workflow-context"; export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDictionary { @@ -17,11 +18,13 @@ export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDi return d; } -function needsJobContext(job?: Job): DescriptionDictionary { +function needsJobContext(job?: WorkflowJob): DescriptionDictionary { // https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context const d = new DescriptionDictionary(); - d.add("outputs", jobOutputs(job)); + if (job && isJob(job)) { + d.add("outputs", jobOutputs(job)); + } // Can be "success", "failure", "cancelled", or "skipped" d.add("result", new data.Null()); diff --git a/actions-languageservice/src/context/workflow-context.test.ts b/actions-languageservice/src/context/workflow-context.test.ts index 4a4d162..1f2a3ad 100644 --- a/actions-languageservice/src/context/workflow-context.test.ts +++ b/actions-languageservice/src/context/workflow-context.test.ts @@ -5,23 +5,20 @@ import {getPositionFromCursor} from "../test-utils/cursor-position"; import {findToken} from "../utils/find-token"; import {getWorkflowContext, WorkflowContext} from "./workflow-context"; -function testGetWorkflowContext(input: string): WorkflowContext { +async function testGetWorkflowContext(input: string): Promise { const [textDocument, pos] = getPositionFromCursor(input); const result = parseWorkflow( - "wf.yaml", - [ - { - content: textDocument.getText(), - name: "wf.yaml" - } - ], + { + content: textDocument.getText(), + name: "wf.yaml" + }, nullTrace ); let template: WorkflowTemplate | undefined; if (result.value) { - template = convertWorkflowTemplate(result.context, result.value); + template = await convertWorkflowTemplate(result.context, result.value); } const {path} = findToken(pos, result.value); @@ -30,8 +27,8 @@ function testGetWorkflowContext(input: string): WorkflowContext { } describe("getWorkflowContext", () => { - it("context for workflow", () => { - const context = testGetWorkflowContext(`on: push + it("context for workflow", async () => { + const context = await testGetWorkflowContext(`on: push name: te|st jobs: build: @@ -44,8 +41,8 @@ jobs: expect(context.step).toBeUndefined(); }); - it("context for workflow job", () => { - const context = testGetWorkflowContext(`on: push + it("context for workflow job", async () => { + const context = await testGetWorkflowContext(`on: push jobs: build: runs-on: ubuntu-lat|est @@ -57,8 +54,8 @@ jobs: expect(context.step).toBeUndefined(); }); - it("context for workflow run step", () => { - const context = testGetWorkflowContext(`on: push + it("context for workflow run step", async () => { + const context = await testGetWorkflowContext(`on: push jobs: build: runs-on: ubuntu-latest @@ -74,8 +71,8 @@ jobs: expect(step.run.toDisplayString()).toBe("echo Hello"); }); - it("context for workflow uses step", () => { - const context = testGetWorkflowContext(`on: push + it("context for workflow uses step", async () => { + const context = await testGetWorkflowContext(`on: push jobs: build: runs-on: ubuntu-latest diff --git a/actions-languageservice/src/context/workflow-context.ts b/actions-languageservice/src/context/workflow-context.ts index 6ba3f28..c4c20af 100644 --- a/actions-languageservice/src/context/workflow-context.ts +++ b/actions-languageservice/src/context/workflow-context.ts @@ -1,5 +1,6 @@ import {isMapping, isSequence, WorkflowTemplate} from "@github/actions-workflow-parser"; -import {Job, Step} from "@github/actions-workflow-parser/model/workflow-template"; +import {isJob, isReusableWorkflowJob} from "@github/actions-workflow-parser/model/type-guards"; +import {Step, Job, ReusableWorkflowJob} from "@github/actions-workflow-parser/model/workflow-template"; import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token"; import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token"; import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; @@ -10,9 +11,12 @@ export interface WorkflowContext { template: WorkflowTemplate | undefined; - /** If the context is for a position within a job, this will be the job */ + /** If the context is for a position within a regular job, this will be the job */ job?: Job; + /** If the context is for a position within a reusable workflow job, this will be the reusable workflow job */ + reusableWorkflowJob?: ReusableWorkflowJob; + /** If the context is for a position within a step, this will be the step */ step?: Step; } @@ -35,7 +39,15 @@ export function getWorkflowContext( switch (token.definition?.key) { case "job": { const jobID = (token as StringToken).value; - context.job = template.jobs.find(job => job.id.value === jobID); + const job = template.jobs.find(job => job.id.value === jobID); + if (!job) { + break; + } + if (isJob(job)) { + context.job = job; + } else if (isReusableWorkflowJob(job)) { + context.reusableWorkflowJob = job; + } break; } case "steps": { @@ -54,7 +66,10 @@ export function getWorkflowContext( } } - context.step = findStep(context.job?.steps, stepsSequence, stepToken); + if (context.job && isJob(context.job)) { + context.step = findStep(context.job.steps, stepsSequence, stepToken); + } + return context; } diff --git a/actions-languageservice/src/document-links.ts b/actions-languageservice/src/document-links.ts index 8bc17cb..a22d358 100644 --- a/actions-languageservice/src/document-links.ts +++ b/actions-languageservice/src/document-links.ts @@ -1,5 +1,6 @@ import {convertWorkflowTemplate, parseWorkflow} from "@github/actions-workflow-parser"; import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; +import {isJob} from "@github/actions-workflow-parser/model/type-guards"; import {File} from "@github/actions-workflow-parser/workflows/file"; import {TextDocument} from "vscode-languageserver-textdocument"; import {DocumentLink} from "vscode-languageserver-types"; @@ -13,12 +14,12 @@ export async function documentLinks(document: TextDocument): Promise { + it("simple expression", () => { + expect( + testMapToExpressionPos(`on: push +run-name: \${{ git|hub.event }}`) + ).toEqual({ + expression: "github.event", + position: {line: 0, column: 3}, + documentRange: { + start: {line: 1, character: 14}, + end: {line: 1, character: 26} + } + }); + }); + + it("implicit format expression", () => { + expect( + testMapToExpressionPos(`on: push +run-name: hello \${{ git|hub.event }}`) + ).toEqual({ + expression: "github.event", + position: {line: 0, column: 3}, + documentRange: { + start: {line: 1, character: 20}, + end: {line: 1, character: 32} + } + }); + }); + + it("implicit complex format expression", () => { + expect( + testMapToExpressionPos(`on: push +run-name: hello \${{ github.test }}-\${{ git|hub.event }}`) + ).toEqual({ + expression: "github.event", + position: {line: 0, column: 3}, + documentRange: { + start: {line: 1, character: 39}, + end: {line: 1, character: 51} + } + }); + }); + + it("multi-line expression", () => { + expect( + testMapToExpressionPos(`on: push +jobs: + build: + runs-on: [self-hosted] + steps: + - run: > + echo 'hello' + echo '\${{ github.event.te|st }} + echo 'world' + echo '\${{ github.event.test }}`) + ).toEqual({ + expression: "github.event.test", + position: {line: 0, column: 15}, + documentRange: { + start: {line: 7, character: 18}, + end: {line: 7, character: 35} + } + }); + }); +}); + +function testMapToExpressionPos(input: string) { + const [td, pos] = getPositionFromCursor(input); + + const file: File = { + name: td.uri, + content: td.getText() + }; + const result = parseWorkflow(file, nullTrace); + if (!result.value) { + throw new Error("Invalid workflow"); + } + + const {token} = findToken(pos, result.value); + if (!token) { + throw new Error("No token found"); + } + + return mapToExpressionPos(token, pos); +} diff --git a/actions-languageservice/src/expression-hover/expression-pos.ts b/actions-languageservice/src/expression-hover/expression-pos.ts new file mode 100644 index 0000000..ad3be03 --- /dev/null +++ b/actions-languageservice/src/expression-hover/expression-pos.ts @@ -0,0 +1,60 @@ +import {Pos} from "@github/actions-expressions/lexer"; +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; +import {isBasicExpression} from "@github/actions-workflow-parser/templates/tokens/type-guards"; +import {Position, Range as LSPRange} from "vscode-languageserver-textdocument"; +import {mapRange} from "../utils/range"; +import {posWithinRange} from "./pos-range"; + +export type ExpressionPos = { + /** The expression that includes the position */ + expression: string; + + /** Adjusted position, pointing into the expression */ + position: Pos; + + /** Range of the expression in the document */ + documentRange: LSPRange; +}; + +export function mapToExpressionPos(token: TemplateToken, position: Position): ExpressionPos | undefined { + const pos: Pos = { + line: position.line + 1, + column: position.character + 1 + }; + + if (!isBasicExpression(token)) { + return undefined; + } + + if (token.originalExpressions?.length) { + for (const originalExp of token.originalExpressions) { + // Find the original expression that contains the position + if (posWithinRange(pos, originalExp.expressionRange!)) { + const exprRange = mapRange(originalExp.expressionRange); + + return { + expression: originalExp.expression, + // Adjust the position to point into the expression + position: { + line: pos.line - exprRange.start.line - 1, + column: pos.column - exprRange.start.character - 1 + }, + documentRange: exprRange + }; + } + } + + return undefined; + } + + const exprRange = mapRange(token.expressionRange!); + return { + expression: token.expression, + // Adjust the position to point into the expression + position: { + line: pos.line - exprRange.start.line - 1, + column: pos.column - exprRange.start.character - 1 + }, + documentRange: exprRange + }; +} diff --git a/actions-languageservice/src/expression-hover/pos-range.ts b/actions-languageservice/src/expression-hover/pos-range.ts new file mode 100644 index 0000000..c40728d --- /dev/null +++ b/actions-languageservice/src/expression-hover/pos-range.ts @@ -0,0 +1,10 @@ +import {Pos, Range} from "@github/actions-expressions/lexer"; + +export function posWithinRange(pos: Pos, range: Range): boolean { + return ( + pos.line >= range.start.line && + pos.line <= range.end.line && + pos.column >= range.start.column && + pos.column <= range.end.column + ); +} diff --git a/actions-languageservice/src/expression-hover/visitor.test.ts b/actions-languageservice/src/expression-hover/visitor.test.ts new file mode 100644 index 0000000..cffefff --- /dev/null +++ b/actions-languageservice/src/expression-hover/visitor.test.ts @@ -0,0 +1,133 @@ +import {data, DescriptionDictionary, Lexer, Parser} from "@github/actions-expressions"; +import {convertWorkflowTemplate, parseWorkflow} from "@github/actions-workflow-parser"; +import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; +import {File} from "@github/actions-workflow-parser/workflows/file"; +import {ContextProviderConfig} from "../context-providers/config"; +import {getContext, Mode} from "../context-providers/default"; +import {getWorkflowContext} from "../context/workflow-context"; +import {validatorFunctions} from "../expression-validation/functions"; +import {nullTrace} from "../nulltrace"; +import {getPositionFromCursor} from "../test-utils/cursor-position"; +import {HoverVisitor} from "./visitor"; + +const contextProviderConfig: ContextProviderConfig = { + getContext: async (context: string) => { + switch (context) { + case "github": + return new DescriptionDictionary( + { + key: "event", + value: new data.StringData("push"), + description: "The event that triggered the workflow" + }, + { + key: "test", + value: new DescriptionDictionary({ + key: "name", + value: new data.StringData("push"), + description: "Name for the test" + }), + description: "Test dictionary" + } + ); + } + + return undefined; + } +}; + +describe("visitor", () => { + describe("unsupported hover positions", () => { + ["1 =|= 2", "12|3", "1 == |(2)", "'ab|c'"].forEach(x => + it(x, async () => expect(await hoverExpression(x)).toBeUndefined()) + ); + }); + + it("top-level context access", async () => { + expect(await hoverExpression("githu|b")).toEqual({ + label: "github", + description: + "Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context).", + function: false, + range: { + start: {line: 0, column: 0}, + end: {line: 0, column: 6} + } + }); + }); + + it("nested context access", async () => { + expect(await hoverExpression("github.test.na|me")).toEqual({ + label: "name", + description: "Name for the test", + function: false, + range: { + start: {line: 0, column: 0}, + end: {line: 0, column: 16} + } + }); + }); + + it("nested context access with string key", async () => { + expect(await hoverExpression("github['te|st']")).toEqual({ + label: "test", + description: "Test dictionary", + function: false, + range: { + start: {line: 0, column: 0}, + end: {line: 0, column: 13} + } + }); + }); + + it("function call", async () => { + expect(await hoverExpression("cont|ains(github, 'github')")).toEqual({ + label: "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.", + function: true, + range: { + start: {line: 0, column: 0}, + end: {line: 0, column: 8} + } + }); + }); +}); + +async function hoverExpression(input: string) { + const [td, pos] = getPositionFromCursor(input); + const allowedContext = ["github"]; + + const file: File = { + name: td.uri, + content: td.getText() + }; + const result = parseWorkflow(file, nullTrace); + if (!result.value) { + return undefined; + } + + const template = await convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const workflowContext = getWorkflowContext(td.uri, template, []); + const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion); + + const l = new Lexer(td.getText()); + const lr = l.lex(); + + const p = new Parser(lr.tokens, ["github"], []); + const expr = p.parse(); + + const hv = new HoverVisitor( + { + line: pos.line, + column: pos.character + }, + context, + [], + validatorFunctions + ); + return hv.hover(expr); +} diff --git a/actions-languageservice/src/expression-hover/visitor.ts b/actions-languageservice/src/expression-hover/visitor.ts new file mode 100644 index 0000000..78f20c9 --- /dev/null +++ b/actions-languageservice/src/expression-hover/visitor.ts @@ -0,0 +1,166 @@ +import { + DescriptionDictionary, + Evaluator, + isDescriptionDictionary, + wellKnownFunctions +} from "@github/actions-expressions"; +import { + Binary, + ContextAccess, + Expr, + ExprVisitor, + FunctionCall, + Grouping, + IndexAccess, + Literal, + Logical, + Unary +} from "@github/actions-expressions/ast"; +import {FunctionDefinition, FunctionInfo} from "@github/actions-expressions/funcs/info"; +import {Pos, Range} from "@github/actions-expressions/lexer"; +import {posWithinRange} from "./pos-range"; + +export type HoverResult = + | undefined + | { + label: string; + description?: string; + function: boolean; + range: Range; + }; + +export class HoverVisitor implements ExprVisitor { + private ignorePosCheck = false; + + constructor( + private pos: Pos, + private context: DescriptionDictionary, + private extensionFunctions: FunctionInfo[], + private functions: Map + ) {} + + hover(n: Expr): HoverResult { + return n.accept(this); + } + + visitLiteral(literal: Literal): HoverResult { + return undefined; + } + + visitUnary(unary: Unary): HoverResult { + return this.hover(unary.expr); + } + + visitBinary(binary: Binary): HoverResult { + return this.hover(binary.left) || this.hover(binary.right); + } + + visitLogical(logical: Logical): HoverResult { + for (const arg of logical.args) { + const result = this.hover(arg); + if (result) { + return result; + } + } + + return undefined; + } + + visitGrouping(grouping: Grouping): HoverResult { + return this.hover(grouping.group); + } + + visitContextAccess(contextAccess: ContextAccess): HoverResult { + if (this.ignorePosCheck || posWithinRange(this.pos, contextAccess.name.range)) { + const contextName = contextAccess.name.lexeme; + + return { + label: contextName, + description: this.context.getDescription(contextName), + function: false, + range: contextAccess.name.range + }; + } + + return undefined; + } + + visitIndexAccess(indexAccess: IndexAccess): HoverResult { + // Is the position within the index, so for example: + // github.event.test + // ^ - pos + if (!(indexAccess.index instanceof Literal)) { + // No support for context access of the form github[github.event] + return undefined; + } + + if (!posWithinRange(this.pos, indexAccess.index.token.range)) { + // Try to get hover from the rest of the expression + return this.hover(indexAccess.expr); + } + + const ev = new Evaluator(indexAccess.expr, this.context, this.functions); + const result = ev.evaluate(); + + if (!isDescriptionDictionary(result)) { + // No description to show + return undefined; + } + + const key = indexAccess.index.literal.coerceString(); + const description = result.getDescription(key); + if (!description) { + return undefined; + } + + // Calculate context access range for whole expression. For example: + // github.event.test + // ^ - pos + // should return the range: + // github.event.test + // ^^^^^^^^^^^^ + this.ignorePosCheck = true; + + try { + const contextHover = this.hover(indexAccess.expr); + if (!contextHover) { + throw new Error("Expected context hover to be defined"); + } + + return { + label: key, + description: description, + function: false, + range: { + start: contextHover.range.start, + end: indexAccess.index.token.range.end + } + }; + } finally { + this.ignorePosCheck = false; + } + } + + visitFunctionCall(functionCall: FunctionCall): HoverResult { + if (posWithinRange(this.pos, functionCall.functionName.range)) { + const functionName = functionCall.functionName.lexeme.toLowerCase(); + const f = this.functions.get(functionName) || wellKnownFunctions[functionName]; + + return { + label: f.name, + description: f.description, + function: true, + range: functionCall.functionName.range + }; + } + + for (const args of functionCall.args) { + const result = this.hover(args); + if (result) { + return result; + } + } + + return undefined; + } +} diff --git a/actions-languageservice/src/hover.expressions.test.ts b/actions-languageservice/src/hover.expressions.test.ts new file mode 100644 index 0000000..6b6bc21 --- /dev/null +++ b/actions-languageservice/src/hover.expressions.test.ts @@ -0,0 +1,116 @@ +import {data, DescriptionDictionary} from "@github/actions-expressions"; +import {Hover} from "vscode-languageserver-types"; +import {ContextProviderConfig} from "./context-providers/config"; +import {hover} from "./hover"; +import {registerLogger} from "./log"; +import {getPositionFromCursor} from "./test-utils/cursor-position"; +import {TestLogger} from "./test-utils/logger"; + +const contextProviderConfig: ContextProviderConfig = { + getContext: async (context: string) => { + switch (context) { + case "github": + return new DescriptionDictionary( + { + key: "event", + value: new data.StringData("push"), + description: "The event that triggered the workflow" + }, + { + key: "test", + value: new DescriptionDictionary({ + key: "name", + value: new data.StringData("push"), + description: "Name for the test" + }), + description: "Test dictionary" + } + ); + } + + return undefined; + } +}; + +registerLogger(new TestLogger()); + +describe("hover.expressions", () => { + it("context access", async () => { + const input = `on: push +run-name: \${{ github.even|t }} +jobs: + build: + runs-on: [self-hosted]`; + const result = await hover(...getPositionFromCursor(input), { + contextProviderConfig + }); + expect(result).toEqual({ + contents: "The event that triggered the workflow", + range: { + start: {line: 1, character: 14}, + end: {line: 1, character: 26} + } + }); + }); + + it("context", async () => { + const input = `on: push +run-name: \${{ git|hub.event }} +jobs: + build: + runs-on: [self-hosted]`; + const result = await hover(...getPositionFromCursor(input), { + contextProviderConfig + }); + expect(result).toEqual({ + contents: + "Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context).", + range: { + start: {line: 1, character: 14}, + end: {line: 1, character: 20} + } + }); + }); + + it("multiple expressions", async () => { + const input = `on: push +run-name: \${{ git|hub.event }}-\${{ github.event }} +jobs: + build: + runs-on: [self-hosted]`; + const result = await hover(...getPositionFromCursor(input), { + contextProviderConfig + }); + expect(result).toEqual({ + contents: + "Information about the workflow run. For more information, see [`github` context](https://docs.github.com/actions/learn-github-actions/contexts#github-context).", + range: { + start: {line: 1, character: 14}, + end: {line: 1, character: 20} + } + }); + }); + + it("multi-line expression", async () => { + const input = `on: push +jobs: + build: + runs-on: [self-hosted] + steps: + - run: | + echo 'hello' + echo '\${{ github.test.na|me }} + echo 'world' + echo '\${{ github.event.test }}`; + const result = await hover(...getPositionFromCursor(input, 1), { + contextProviderConfig + }); + expect(result).toEqual({ + contents: "Name for the test", + range: { + start: {line: 7, character: 18}, + end: {line: 7, character: 34} + } + }); + }); +}); diff --git a/actions-languageservice/src/hover.test.ts b/actions-languageservice/src/hover.test.ts index fe017dc..b92717d 100644 --- a/actions-languageservice/src/hover.test.ts +++ b/actions-languageservice/src/hover.test.ts @@ -3,7 +3,7 @@ import {StringToken} from "@github/actions-workflow-parser/templates/tokens/stri import {DescriptionProvider, hover, HoverConfig} from "./hover"; import {getPositionFromCursor} from "./test-utils/cursor-position"; -function testHoverConfig(tokenValue: string, tokenKey: string, description?: string) { +export function testHoverConfig(tokenValue: string, tokenKey: string, description?: string) { return { descriptionProvider: { getDescription: async (_, token, __) => { diff --git a/actions-languageservice/src/hover.ts b/actions-languageservice/src/hover.ts index eab154e..b36cf16 100644 --- a/actions-languageservice/src/hover.ts +++ b/actions-languageservice/src/hover.ts @@ -1,37 +1,69 @@ +import {DescriptionDictionary, Parser} from "@github/actions-expressions"; +import {FunctionInfo} from "@github/actions-expressions/funcs/info"; +import {Lexer} from "@github/actions-expressions/lexer"; import {convertWorkflowTemplate, parseWorkflow, ParseWorkflowResult} from "@github/actions-workflow-parser"; import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; -import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; -import {TokenResult} from "./utils/find-token"; -import {File} from "@github/actions-workflow-parser/workflows/file"; -import {isString} from "@github/actions-workflow-parser/templates/tokens/type-guards"; +import {getCronDescription} from "@github/actions-workflow-parser/model/converter/cron"; +import {splitAllowedContext} from "@github/actions-workflow-parser/templates/allowed-context"; import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; +import {isBasicExpression, isString} from "@github/actions-workflow-parser/templates/tokens/type-guards"; +import {File} from "@github/actions-workflow-parser/workflows/file"; import {Position, TextDocument} from "vscode-languageserver-textdocument"; import {Hover} from "vscode-languageserver-types"; +import {ContextProviderConfig} from "./context-providers/config"; +import {getContext, Mode} from "./context-providers/default"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; +import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-pos"; +import {HoverVisitor} from "./expression-hover/visitor"; +import {validatorFunctions} from "./expression-validation/functions"; import {info} from "./log"; import {nullTrace} from "./nulltrace"; -import {findToken} from "./utils/find-token"; +import {isPotentiallyExpression} from "./utils/expression-detection"; +import {findToken, TokenResult} from "./utils/find-token"; import {mapRange} from "./utils/range"; -import {getCronDescription} from "@github/actions-workflow-parser/model/converter/cron"; + +export type HoverConfig = { + descriptionProvider?: DescriptionProvider; + contextProviderConfig?: ContextProviderConfig; +}; export type DescriptionProvider = { getDescription(context: WorkflowContext, token: TemplateToken, path: TemplateToken[]): Promise; }; -export type HoverConfig = { - descriptionProvider?: DescriptionProvider; -}; - -// Render value description and Context when hovering over a key in a MappingToken export async function hover(document: TextDocument, position: Position, config?: HoverConfig): Promise { const file: File = { name: document.uri, content: document.getText() }; - const result = parseWorkflow(file.name, [file], nullTrace); + const result = parseWorkflow(file, nullTrace); + if (!result.value) { + return null; + } const tokenResult = findToken(position, result.value); - const token = tokenResult.token; + const {token, keyToken, parent} = tokenResult; + + const tokenDefinitionInfo = (keyToken || parent || token)?.definitionInfo; + if (token && tokenDefinitionInfo) { + if (isBasicExpression(token) || isPotentiallyExpression(token)) { + info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`); + + const allowedContext = tokenDefinitionInfo.allowedContext || []; + const {namedContexts, functions} = splitAllowedContext(allowedContext); + + const template = await convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const workflowContext = getWorkflowContext(document.uri, template, tokenResult.path); + const context = await getContext(namedContexts, config?.contextProviderConfig, workflowContext, Mode.Completion); + + const exprPos = mapToExpressionPos(token, position); + if (exprPos) { + return expressionHover(exprPos, context, namedContexts, functions); + } + } + } + if (!token?.definition) { return null; } @@ -40,7 +72,7 @@ export async function hover(document: TextDocument, position: Position, config?: if (tokenResult.parent && isCronMappingValue(tokenResult)) { const tokenValue = (token as StringToken).value; - let description = getCronDescription(tokenValue); + const description = getCronDescription(tokenValue); if (description) { return { contents: description, @@ -75,7 +107,7 @@ async function getDescription( return defaultDescription; } - const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const template = await convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); const workflowContext = getWorkflowContext(document.uri, template, path); const description = await config.descriptionProvider.getDescription(workflowContext, token, path); return description || defaultDescription; @@ -88,3 +120,47 @@ function isCronMappingValue(tokenResult: TokenResult): boolean { tokenResult.token.value !== "cron" ); } + +function expressionHover( + exprPos: ExpressionPos, + context: DescriptionDictionary, + namedContexts: string[], + functions: FunctionInfo[] +): Hover | null { + const {expression, position, documentRange} = exprPos; + + try { + const l = new Lexer(expression); + const lr = l.lex(); + + const p = new Parser(lr.tokens, namedContexts, functions); + const expr = p.parse(); + + const hv = new HoverVisitor(position, context, [], validatorFunctions); + const hoverResult = hv.hover(expr); + if (!hoverResult) { + return null; + } + + const exprRange = hoverResult.range; + + return { + contents: hoverResult?.description || hoverResult?.label, + // Map the expression range back to a document range + range: { + start: { + line: documentRange.start.line + exprRange.start.line, + character: documentRange.start.character + exprRange.start.column + }, + end: { + line: documentRange.start.line + exprRange.end.line, + character: documentRange.start.character + exprRange.end.column + } + } + }; + } catch (e) { + // Hovering over an invalid expression should not cause an error here + info(`Encountered error trying to calculate expression hover: ${e}`); + return null; + } +} diff --git a/actions-languageservice/src/utils/expression-detection.ts b/actions-languageservice/src/utils/expression-detection.ts new file mode 100644 index 0000000..2242d8a --- /dev/null +++ b/actions-languageservice/src/utils/expression-detection.ts @@ -0,0 +1,12 @@ +import {isString} from "@github/actions-workflow-parser"; +import {DefinitionType} from "@github/actions-workflow-parser/templates/schema/definition-type"; +import {StringDefinition} from "@github/actions-workflow-parser/templates/schema/string-definition"; +import {OPEN_EXPRESSION} from "@github/actions-workflow-parser/templates/template-constants"; +import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/index"; + +export function isPotentiallyExpression(token: TemplateToken): boolean { + const isAlwaysExpression = + token.definition?.definitionType === DefinitionType.String && (token.definition as StringDefinition).isExpression; + const containsExpression = isString(token) && token.value.indexOf(OPEN_EXPRESSION) >= 0; + return isAlwaysExpression || containsExpression; +} diff --git a/actions-languageservice/src/utils/find-token.test.ts b/actions-languageservice/src/utils/find-token.test.ts index d639fde..003abf0 100644 --- a/actions-languageservice/src/utils/find-token.test.ts +++ b/actions-languageservice/src/utils/find-token.test.ts @@ -27,13 +27,10 @@ function testFindToken(input: string): { } { const [textDocument, pos] = getPositionFromCursor(input); const result = parseWorkflow( - "wf.yaml", - [ - { - content: textDocument.getText(), - name: "wf.yaml" - } - ], + { + content: textDocument.getText(), + name: "wf.yaml" + }, nullTrace ); diff --git a/actions-languageservice/src/utils/rel-char-pos.ts b/actions-languageservice/src/utils/rel-char-pos.ts new file mode 100644 index 0000000..3ffa100 --- /dev/null +++ b/actions-languageservice/src/utils/rel-char-pos.ts @@ -0,0 +1,15 @@ +import {TokenRange} from "@github/actions-workflow-parser/templates/tokens/token-range"; +import {Position} from "vscode-languageserver-textdocument"; +import {mapRange} from "./range"; + +export function getRelCharOffset(tokenRange: TokenRange, currentInput: string, pos: Position): number { + const range = mapRange(tokenRange); + if (range.start.line !== range.end.line) { + const lines = currentInput.split("\n"); + const lineDiff = pos.line - range.start.line - 1; + const linesBeforeCusor = lines.slice(0, lineDiff); + return linesBeforeCusor.join("\n").length + pos.character + 1; + } else { + return pos.character - range.start.character; + } +} diff --git a/actions-languageservice/src/validate.ts b/actions-languageservice/src/validate.ts index 3477abb..ca91193 100644 --- a/actions-languageservice/src/validate.ts +++ b/actions-languageservice/src/validate.ts @@ -14,6 +14,7 @@ import {BasicExpressionToken} from "@github/actions-workflow-parser/templates/to import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; import {File} from "@github/actions-workflow-parser/workflows/file"; +import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider"; import {TextDocument} from "vscode-languageserver-textdocument"; import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types"; import {ActionInputs, ActionReference} from "./action"; @@ -35,6 +36,7 @@ export type ValidationConfig = { valueProviderConfig?: ValueProviderConfig; contextProviderConfig?: ContextProviderConfig; getActionInputs?(action: ActionReference): Promise; + fileProvider?: FileProvider; }; /** @@ -43,11 +45,7 @@ export type ValidationConfig = { * @param textDocument Document to validate * @returns Array of diagnostics */ -export async function validate( - textDocument: TextDocument, - config?: ValidationConfig - // TODO: Support multiple files, context for API calls -): Promise { +export async function validate(textDocument: TextDocument, config?: ValidationConfig): Promise { const file: File = { name: textDocument.uri, content: textDocument.getText() @@ -56,10 +54,18 @@ export async function validate( const diagnostics: Diagnostic[] = []; try { - const result: ParseWorkflowResult = parseWorkflow(file.name, [file], nullTrace); + const result: ParseWorkflowResult = parseWorkflow(file, nullTrace); if (result.value) { // Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information - const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const template = await convertWorkflowTemplate( + result.context, + result.value, + ErrorPolicy.TryConversion, + config?.fileProvider, + { + fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0 + } + ); // Validate expressions and value providers await additionalValidations(diagnostics, textDocument.uri, template, result.value, config); diff --git a/actions-languageservice/src/validate.workflow-references.test.ts b/actions-languageservice/src/validate.workflow-references.test.ts new file mode 100644 index 0000000..e9f256a --- /dev/null +++ b/actions-languageservice/src/validate.workflow-references.test.ts @@ -0,0 +1,208 @@ +import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider"; +import {fileIdentifier} from "@github/actions-workflow-parser/workflows/file-reference"; +import {createDocument} from "./test-utils/document"; +import {validate} from "./validate"; + +const testFileProvider: FileProvider = { + getFileContent: async ref => { + switch (fileIdentifier(ref)) { + case "monalisa/octocat/workflow.yaml@main": + return { + name: "monalisa/octocat/workflow.yaml", + content: ` +on: workflow_call +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 +` + }; + + case "monalisa/octocat/.github/workflows/non-reusable-workflow.yaml@main": + return { + name: "monalisa/octocat/.github/workflows/non-reusable-workflow.yaml", + content: ` +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 +` + }; + + case "./reusable-workflow.yaml": + return { + name: "reusable-workflow.yaml", + content: ` +on: workflow_call +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 +` + }; + + case "./reusable-workflow-with-inputs.yaml": + return { + name: "reusable-workflow-with-inputs.yaml", + content: ` +on: + workflow_call: + inputs: + username: + description: 'A username passed from the caller workflow' + required: true + type: string + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + ` + }; + + default: + throw new Error("File not found"); + } + } +}; + +describe("workflow references validation", () => { + it("invalid workflow reference", async () => { + const input = ` +on: push + +jobs: + build: + uses: monalisa/octocat/.github/workflows/non-reusable-workflow.yaml@main +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([ + { + message: "workflow_call key is not defined in the referenced workflow.", + range: { + start: { + character: 10, + line: 5 + }, + end: { + character: 76, + line: 5 + } + } + } + ]); + }); + + it("reference to a non-reusable workflow", async () => { + const input = ` +on: push + +jobs: + build: + uses: monalisa/octocat/workflow.yaml@not-a-branch +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([ + { + message: "Unable to find reusable workflow", + range: { + start: { + character: 10, + line: 5 + }, + end: { + character: 53, + line: 5 + } + } + } + ]); + }); + + it("valid reference to a reusable workflow", async () => { + const input = ` +on: push + +jobs: + build: + uses: monalisa/octocat/workflow.yaml@main +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([]); + }); + + it("valid reference to a local workflow", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow.yaml +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([]); + }); + + it("workflow reference without required inputs", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs.yaml +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([ + { + message: "Input username is required, but not provided while calling.", + range: { + start: { + character: 10, + line: 5 + }, + end: { + character: 46, + line: 5 + } + } + } + ]); + }); + + it("workflow reference with required inputs", async () => { + const input = ` +on: push + +jobs: + build: + uses: ./reusable-workflow-with-inputs.yaml + with: + username: monalisa +`; + const result = await validate(createDocument("wf.yaml", input), { + fileProvider: testFileProvider + }); + + expect(result).toEqual([]); + }); +}); diff --git a/actions-workflow-parser/.prettierignore b/actions-workflow-parser/.prettierignore new file mode 100644 index 0000000..ce98533 --- /dev/null +++ b/actions-workflow-parser/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +*.md +*.js +*.json diff --git a/actions-workflow-parser/package.json b/actions-workflow-parser/package.json index 264de56..5a007f1 100644 --- a/actions-workflow-parser/package.json +++ b/actions-workflow-parser/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-workflow-parser", - "version": "0.1.113", + "version": "0.1.126", "license": "MIT", "type": "module", "source": "./src/index.ts", @@ -40,7 +40,7 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@github/actions-expressions": "^0.1.113", + "@github/actions-expressions": "^0.1.126", "cronstrue": "^2.21.0", "yaml": "^2.0.0-8" }, diff --git a/actions-workflow-parser/src/expressions.test.ts b/actions-workflow-parser/src/expressions.test.ts index 8bdc916..1e46282 100644 --- a/actions-workflow-parser/src/expressions.test.ts +++ b/actions-workflow-parser/src/expressions.test.ts @@ -6,19 +6,16 @@ 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 + { + 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 ); @@ -44,20 +41,17 @@ jobs: it("preserves original expressions when building format for multi-line strings", () => { const result = parseWorkflow( - "test.yaml", - [ - { - name: "test.yaml", - content: `on: push + { + name: "test.yaml", + content: `on: push jobs: build: runs-on: ubuntu-latest steps: - run: | echo \${{ github.event_name }} - echo 'hello' \${{ github.ref }}` - } - ], + echo 'hello' \${{github.ref }}` + }, nullTrace ); @@ -81,19 +75,27 @@ jobs: } expect(stepRun.originalExpressions).toHaveLength(2); - expect(stepRun.originalExpressions!.map(x => [x.toDisplayString(), x.range])).toEqual([ + expect(stepRun.originalExpressions!.map(x => [x.toDisplayString(), x.range, x.expressionRange])).toEqual([ [ "${{ github.event_name }}", { start: {line: 7, column: 16}, end: {line: 7, column: 40} + }, + { + start: {line: 7, column: 20}, + end: {line: 7, column: 37} } ], [ "${{ github.ref }}", { start: {line: 8, column: 24}, - end: {line: 8, column: 41} + end: {line: 8, column: 40} + }, + { + start: {line: 8, column: 27}, + end: {line: 8, column: 37} } ] ]); @@ -101,11 +103,9 @@ jobs: it("return errors and string token with preserved expressions for (multiple) expression errors", () => { const result = parseWorkflow( - "test.yaml", - [ - { - name: "test.yaml", - content: `on: push + { + name: "test.yaml", + content: `on: push jobs: build: runs-on: ubuntu-latest @@ -113,8 +113,7 @@ jobs: - run: | echo \${{ abc }} echo 'hello' \${{ gith }}` - } - ], + }, nullTrace ); @@ -139,11 +138,9 @@ jobs: it("reports all errors for multi-line expressions at the correct locations", () => { const result = parseWorkflow( - "test.yaml", - [ - { - name: "test.yaml", - content: `on: push + { + name: "test.yaml", + content: `on: push jobs: build: runs-on: ubuntu-latest @@ -151,8 +148,7 @@ jobs: - run: | echo \${{ fromJSON2('test') }} echo 'hello' \${{ toJSON2(inputs.test) }}` - } - ], + }, nullTrace ); @@ -178,19 +174,16 @@ jobs: it("parses isExpression strings into expression tokens", () => { const result = parseWorkflow( - "test.yaml", - [ - { - name: "test.yaml", - content: `on: push + { + name: "test.yaml", + content: `on: push jobs: build: runs-on: ubuntu-latest if: github.event_name == 'push' steps: - run: echo 'hello'` - } - ], + }, nullTrace ); diff --git a/actions-workflow-parser/src/index.test.ts b/actions-workflow-parser/src/index.test.ts index b316089..c1e2372 100644 --- a/actions-workflow-parser/src/index.test.ts +++ b/actions-workflow-parser/src/index.test.ts @@ -5,13 +5,10 @@ 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'" - } - ], + { + name: "test.yaml", + content: "on: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'hello'" + }, nullTrace ); @@ -20,13 +17,10 @@ describe("parseWorkflow", () => { 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'" - } - ], + { + name: "test.yaml", + content: "on: push\njobs:\n build:\n steps:\n - run: echo 'hello'" + }, nullTrace ); @@ -40,19 +34,16 @@ describe("parseWorkflow", () => { it("error range for expression is constrained to scalar node", () => { const result = parseWorkflow( - "test.yaml", - [ - { - name: "test.yaml", - content: `on: push + { + name: "test.yaml", + content: `on: push jobs: build: runs-on: ubuntu-latest steps: - name: \${{ github.event = 12 }} run: echo 'hello'` - } - ], + }, nullTrace ); @@ -71,14 +62,11 @@ jobs: 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'" - } - ], + { + name: "test.yaml", + content: + "on: push\nname: hello\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'hello'" + }, nullTrace ); diff --git a/actions-workflow-parser/src/model/convert.test.ts b/actions-workflow-parser/src/model/convert.test.ts index fa75ae8..78b1f68 100644 --- a/actions-workflow-parser/src/model/convert.test.ts +++ b/actions-workflow-parser/src/model/convert.test.ts @@ -7,22 +7,19 @@ function serializeTemplate(template: unknown): unknown { } describe("convertWorkflowTemplate", () => { - it("converts workflow with one job", () => { + it("converts workflow with one job", async () => { const result = parseWorkflow( - "wf.yaml", - [ - { - name: "wf.yaml", - content: `on: push + { + name: "wf.yaml", + content: `on: push jobs: build: runs-on: ubuntu-latest` - } - ], + }, nullTrace ); - const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); + const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); expect(serializeTemplate(template)).toEqual({ events: { @@ -46,13 +43,11 @@ jobs: }); }); - it("converts workflow if expressions", () => { + it("converts workflow if expressions", async () => { const result = parseWorkflow( - "wf.yaml", - [ - { - name: "wf.yaml", - content: `on: push + { + name: "wf.yaml", + content: `on: push jobs: build: if: \${{ true }} @@ -60,12 +55,11 @@ jobs: deploy: if: true runs-on: ubuntu-latest` - } - ], + }, nullTrace ); - const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); + const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); expect(serializeTemplate(template)).toEqual({ events: { @@ -98,23 +92,20 @@ jobs: }); }); - it("converts workflow with empty needs", () => { + it("converts workflow with empty needs", async () => { const result = parseWorkflow( - "wf.yaml", - [ - { - name: "wf.yaml", - content: `on: push + { + 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); + const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); expect(serializeTemplate(template)).toEqual({ errors: [ @@ -143,13 +134,11 @@ jobs: }); }); - it("converts workflow with needs errors", () => { + it("converts workflow with needs errors", async () => { const result = parseWorkflow( - "wf.yaml", - [ - { - name: "wf.yaml", - content: `on: push + { + name: "wf.yaml", + content: `on: push jobs: job1: needs: [unknown-job, job3] @@ -159,12 +148,11 @@ jobs: job3: needs: job1 runs-on: ubuntu-latest` - } - ], + }, nullTrace ); - const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); + const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); expect(serializeTemplate(template)).toEqual({ errors: [ @@ -223,13 +211,11 @@ jobs: }); }); - it("converts workflow with invalid on", () => { + it("converts workflow with invalid on", async () => { const result = parseWorkflow( - "wf.yaml", - [ - { - name: "wf.yaml", - content: `on: + { + name: "wf.yaml", + content: `on: workflow_dispatch: inputs: test: @@ -239,12 +225,11 @@ jobs: runs-on: ubuntu-latest steps: - run: echo hello` - } - ], + }, nullTrace ); - const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); + const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); expect(template.jobs).not.toBeUndefined(); expect(template.jobs).toHaveLength(1); @@ -286,21 +271,18 @@ jobs: }); }); - it("converts workflow with invalid jobs", () => { + it("converts workflow with invalid jobs", async () => { const result = parseWorkflow( - "wf.yaml", - [ - { - name: "wf.yaml", - content: `on: push + { + name: "wf.yaml", + content: `on: push jobs: build:` - } - ], + }, nullTrace ); - const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); + const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion); expect(template.jobs).not.toBeUndefined(); expect(template.jobs).toHaveLength(0); diff --git a/actions-workflow-parser/src/model/convert.ts b/actions-workflow-parser/src/model/convert.ts index 3bd9350..944ba75 100644 --- a/actions-workflow-parser/src/model/convert.ts +++ b/actions-workflow-parser/src/model/convert.ts @@ -1,9 +1,14 @@ import {TemplateContext} from "../templates/template-context"; import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token"; +import {FileProvider} from "../workflows/file-provider"; +import {parseFileReference} from "../workflows/file-reference"; +import {parseWorkflow} from "../workflows/workflow-parser"; import {convertConcurrency} from "./converter/concurrency"; import {convertOn} from "./converter/events"; import {handleTemplateTokenErrors} from "./converter/handle-errors"; import {convertJobs} from "./converter/jobs"; +import {convertReferencedWorkflow} from "./converter/referencedWorkflow"; +import {isReusableWorkflowJob} from "./type-guards"; import {WorkflowTemplate} from "./workflow-template"; export enum ErrorPolicy { @@ -11,11 +16,35 @@ export enum ErrorPolicy { TryConversion } -export function convertWorkflowTemplate( +export type WorkflowTemplateConverterOptions = { + /** + * The maximum depth of reusable workflows allowed in the workflow. + * If this depth is exceeded, an error will be reported. + * If {@link fetchReusableWorkflowDepth} is less than this value, the maximum depth + * won't be enforced. + * Default: 4 + */ + maxReusableWorkflowDepth?: number; + /** + * The depth to fetch reusable workflows, up to {@link maxReusableWorkflowDepth}. + * Currently only a fetch depth of 0 or 1 is supported. + * Default: 0 + */ + fetchReusableWorkflowDepth?: number; +}; + +const defaultOptions: Required = { + maxReusableWorkflowDepth: 4, + fetchReusableWorkflowDepth: 0 +}; + +export async function convertWorkflowTemplate( context: TemplateContext, root: TemplateToken, - errorPolicy: ErrorPolicy = ErrorPolicy.ReturnErrorsOnly -): WorkflowTemplate { + errorPolicy: ErrorPolicy = ErrorPolicy.ReturnErrorsOnly, + fileProvider?: FileProvider, + options: WorkflowTemplateConverterOptions = defaultOptions +): Promise { const result = {} as WorkflowTemplate; if (context.errors.getErrors().length > 0 && errorPolicy === ErrorPolicy.ReturnErrorsOnly) { @@ -25,6 +54,12 @@ export function convertWorkflowTemplate( return result; } + const opts = getOptionsWithDefaults(options); + + if (fileProvider === undefined && opts.fetchReusableWorkflowDepth > 0) { + context.error(root, new Error("A file provider is required to fetch reusable workflows")); + } + try { const rootMapping = root.assertMapping("root"); @@ -49,6 +84,31 @@ export function convertWorkflowTemplate( break; } } + + // Load referenced workflows + for (const job of result.jobs || []) { + if (isReusableWorkflowJob(job)) { + if (opts.maxReusableWorkflowDepth === 0) { + context.error(job.ref, new Error("Reusable workflows are not allowed")); + continue; + } + + if (opts.fetchReusableWorkflowDepth === 0 || fileProvider === undefined) { + continue; + } + + try { + const file = await fileProvider.getFileContent(parseFileReference(job.ref.value)); + const workflow = parseWorkflow(file, context); + if (!workflow.value) { + continue; + } + convertReferencedWorkflow(context, workflow.value, job); + } catch { + context.error(job.ref, new Error("Unable to find reusable workflow")); + } + } + } } catch (err) { if (err instanceof TemplateTokenError) { context.error(err.token, err); @@ -66,3 +126,16 @@ export function convertWorkflowTemplate( return result; } + +function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Required { + return { + maxReusableWorkflowDepth: + options.maxReusableWorkflowDepth !== undefined + ? options.maxReusableWorkflowDepth + : defaultOptions.maxReusableWorkflowDepth, + fetchReusableWorkflowDepth: + options.fetchReusableWorkflowDepth !== undefined + ? options.fetchReusableWorkflowDepth + : defaultOptions.fetchReusableWorkflowDepth + }; +} diff --git a/actions-workflow-parser/src/model/converter/job.ts b/actions-workflow-parser/src/model/converter/job.ts new file mode 100644 index 0000000..ee5f30e --- /dev/null +++ b/actions-workflow-parser/src/model/converter/job.ts @@ -0,0 +1,145 @@ +import {TemplateContext} from "../../templates/template-context"; +import {StringToken, MappingToken, BasicExpressionToken, TemplateToken, ScalarToken} from "../../templates/tokens"; +import {isSequence, isString} from "../../templates/tokens/type-guards"; +import {WorkflowJob, Step} from "../workflow-template"; +import {convertConcurrency} from "./concurrency"; +import {convertToJobContainer, convertToJobServices} from "./container"; +import {handleTemplateTokenErrors} from "./handle-errors"; +import {IdBuilder} from "./id-builder"; +import {convertToActionsEnvironmentRef} from "./job/environment"; +import {convertRunsOn} from "./job/runs-on"; +import {convertSteps} from "./steps"; + +export function convertJob(context: TemplateContext, jobKey: StringToken, token: MappingToken): WorkflowJob { + const error = new IdBuilder().tryAddKnownId(jobKey.value); + if (error) { + context.error(jobKey, error); + } + + let concurrency, container, env, environment, name, outputs, runsOn, services, strategy: TemplateToken | undefined; + let needs: StringToken[] | undefined = undefined; + let steps: Step[] = []; + let workflowJobRef: StringToken | undefined; + let workflowJobInputs: MappingToken | undefined; + + for (const item of token) { + const propertyName = item.key.assertString("job property name"); + switch (propertyName.value) { + case "concurrency": + handleTemplateTokenErrors(item.value, context, undefined, () => convertConcurrency(context, item.value)); + concurrency = item.value; + break; + + case "container": + convertToJobContainer(context, item.value); + container = item.value; + break; + + case "env": + env = item.value.assertMapping("job env"); + break; + + case "environment": + handleTemplateTokenErrors(item.value, context, undefined, () => + convertToActionsEnvironmentRef(context, item.value) + ); + environment = item.value; + break; + + case "name": + name = item.value.assertScalar("job name"); + break; + + case "needs": { + needs = []; + if (isString(item.value)) { + const jobNeeds = item.value.assertString("job needs id"); + needs.push(jobNeeds); + } + + if (isSequence(item.value)) { + for (const seqItem of item.value) { + const jobNeeds = seqItem.assertString("job needs id"); + needs.push(jobNeeds); + } + } + break; + } + + case "outputs": + outputs = item.value.assertMapping("job outputs"); + break; + + case "runs-on": + handleTemplateTokenErrors(item.value, context, undefined, () => convertRunsOn(context, item.value)); + runsOn = item.value; + break; + + case "services": + convertToJobServices(context, item.value); + services = item.value; + break; + + case "steps": + steps = convertSteps(context, item.value); + break; + + case "strategy": + strategy = item.value; + break; + + case "uses": + workflowJobRef = item.value.assertString("job uses value"); + break; + + case "with": + workflowJobInputs = item.value.assertMapping("uses-with value"); + break; + } + } + + if (workflowJobRef !== undefined) { + return { + type: "reusableWorkflowJob", + id: jobKey, + name: jobName(name, jobKey), + needs: needs ?? [], + if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined), + ref: workflowJobRef, + "input-definitions": undefined, + "input-values": workflowJobInputs, + outputs: undefined, + concurrency, + strategy + }; + } else { + return { + type: "job", + id: jobKey, + name: jobName(name, jobKey), + needs, + if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined), + env, + concurrency, + environment, + strategy, + "runs-on": runsOn, + container, + services, + outputs, + steps + }; + } +} + +function jobName(name: ScalarToken | undefined, jobKey: StringToken): ScalarToken { + if (name === undefined) { + return jobKey; + } + + if (isString(name) && name.value === "") { + return jobKey; + } + + return name; +} diff --git a/actions-workflow-parser/src/model/converter/job/inputs.ts b/actions-workflow-parser/src/model/converter/job/inputs.ts new file mode 100644 index 0000000..ebd9467 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/job/inputs.ts @@ -0,0 +1,54 @@ +import {TemplateContext} from "../../../templates/template-context"; +import {MappingToken, TemplateToken} from "../../../templates/tokens"; +import {ReusableWorkflowJob} from "../../workflow-template"; + +type TokenMap = Map; + +export function convertWorkflowJobInputs(context: TemplateContext, job: ReusableWorkflowJob) { + const inputDefinitions = createTokenMap( + job["input-definitions"]?.assertMapping("workflow job input definitions"), + "inputs" + ); + + const inputValues = createTokenMap(job["input-values"]?.assertMapping("workflow job input values"), "with"); + + if (inputDefinitions !== undefined) { + for (const [_, [name, value]] of inputDefinitions) { + const inputSpec = createTokenMap(value.assertMapping(`input ${name}`), `input ${name} key`)!; + + const inputTypeToken = inputSpec.get("type")?.[1]; + if (!inputTypeToken) { + // This should be validated by the template reader per the schema + continue; + } + + const inputSet = inputValues !== undefined && inputValues.has(name.toLowerCase()); + const required = inputSpec.get("required")?.[1].assertBoolean(`input ${name} required`).value; + if (required && !inputSet) { + context.error(job.ref, `Input ${name} is required, but not provided while calling.`); + } + } + } + + if (inputValues !== undefined) { + for (const [_, [name, value]] of inputValues) { + if (!inputDefinitions?.has(name.toLowerCase())) { + context.error(value, `Invalid input, ${name} is not defined in the referenced workflow.`); + } + } + } +} + +function createTokenMap(mapping: MappingToken | undefined, description: string): TokenMap | undefined { + if (!mapping) { + return undefined; + } + + const result = new Map(); + for (const item of mapping) { + const name = item.key.assertString(`${description} key`); + result.set(name.value.toLowerCase(), [name.value, item.value]); + } + + return result; +} diff --git a/actions-workflow-parser/src/model/converter/job/runs-on.ts b/actions-workflow-parser/src/model/converter/job/runs-on.ts new file mode 100644 index 0000000..c96da91 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/job/runs-on.ts @@ -0,0 +1,102 @@ +import {TemplateContext} from "../../../templates/template-context"; +import {TemplateToken} from "../../../templates/tokens"; +import {isMapping, isString, isSequence} from "../../../templates/tokens/type-guards"; + +type RunsOn = { + labels: Set; + group: string; +}; + +export function convertRunsOn(context: TemplateContext, token: TemplateToken): RunsOn { + const labels = convertRunsOnLabels(token); + + if (!isMapping(token)) { + return { + labels, + group: "" + }; + } + + let group = ""; + + for (const item of token) { + const key = item.key.assertString("job runs-on property name"); + switch (key.value) { + case "group": { + if (item.value.isExpression) { + continue; + } + + const groupName = item.value.assertString("job runs-on group name").value; + const names = groupName.split("/"); + switch (names.length) { + case 1: { + group = groupName; + break; + } + case 2: { + if (!["org", "organization", "ent", "enterprise"].includes(names[0])) { + context.error( + item.value, + `Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'` + ); + continue; + } + if (!names[1]) { + context.error(item.value, `Invalid runs-on group name '${groupName}'.`); + continue; + } + + group = groupName; + break; + } + default: { + context.error( + item.value, + `Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'` + ); + break; + } + } + break; + } + case "labels": { + const mapLabels = convertRunsOnLabels(item.value); + for (const label of mapLabels) { + labels.add(label); + } + break; + } + } + } + + return { + labels, + group + }; +} + +function convertRunsOnLabels(token: TemplateToken): Set { + 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/jobs.ts b/actions-workflow-parser/src/model/converter/jobs.ts index a70cef7..50152b9 100644 --- a/actions-workflow-parser/src/model/converter/jobs.ts +++ b/actions-workflow-parser/src/model/converter/jobs.ts @@ -1,23 +1,19 @@ import {TemplateContext} from "../../templates/template-context"; -import {BasicExpressionToken, MappingToken, StringToken} from "../../templates/tokens"; +import {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 {isMapping} from "../../templates/tokens/type-guards"; +import {WorkflowJob} from "../workflow-template"; import {handleTemplateTokenErrors} from "./handle-errors"; -import {IdBuilder} from "./id-builder"; -import {convertToActionsEnvironmentRef} from "./job/environment"; -import {convertSteps} from "./steps"; +import {convertJob} from "./job"; type nodeInfo = { name: string; needs: StringToken[]; }; -export function convertJobs(context: TemplateContext, token: TemplateToken): Job[] { +export function convertJobs(context: TemplateContext, token: TemplateToken): WorkflowJob[] { if (isMapping(token)) { - const result: Job[] = []; + const result: WorkflowJob[] = []; const jobsWithSatisfiedNeeds: nodeInfo[] = []; const alljobsWithUnsatisfiedNeeds: nodeInfo[] = []; @@ -53,7 +49,7 @@ export function convertJobs(context: TemplateContext, token: TemplateToken): Job function validateNeeds( token: TemplateToken, context: TemplateContext, - result: Job[], + result: WorkflowJob[], jobsWithSatisfiedNeeds: nodeInfo[], alljobsWithUnsatisfiedNeeds: nodeInfo[] ) { @@ -101,199 +97,3 @@ function validateNeeds( } } -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, "success()"), - 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/referencedWorkflow.ts b/actions-workflow-parser/src/model/converter/referencedWorkflow.ts new file mode 100644 index 0000000..9de9b49 --- /dev/null +++ b/actions-workflow-parser/src/model/converter/referencedWorkflow.ts @@ -0,0 +1,97 @@ +import {TemplateContext} from "../../templates/template-context"; +import {TemplateToken} from "../../templates/tokens"; +import {TokenType} from "../../templates/tokens/types"; +import {ReusableWorkflowJob} from "../workflow-template"; +import {handleTemplateTokenErrors} from "./handle-errors"; +import {convertWorkflowJobInputs} from "./job/inputs"; +import {convertJobs} from "./jobs"; + +export function convertReferencedWorkflow( + context: TemplateContext, + referencedWorkflow: TemplateToken, + job: ReusableWorkflowJob +) { + const mapping = referencedWorkflow.assertMapping("root"); + + // The language service doesn't currently handles on other documents, + // So use the ref in the original workflow as the error location + const tokenForErrors = job.ref; + + for (const pair of mapping) { + const key = pair.key.assertString("root key"); + switch (key.value) { + case "on": { + handleTemplateTokenErrors(tokenForErrors, context, undefined, () => + convertReferencedWorkflowOn(context, pair.value, job) + ); + break; + } + case "jobs": { + job.jobs = handleTemplateTokenErrors(tokenForErrors, context, [], () => convertJobs(context, pair.value)); + break; + } + } + } +} + +function convertReferencedWorkflowOn(context: TemplateContext, on: TemplateToken, job: ReusableWorkflowJob) { + const tokenForErrors = job.ref; + switch (on.templateTokenType) { + case TokenType.String: { + const event = on.assertString("Reference workflow on value").value; + if (event === "workflow_call") { + handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job)); + return; + } + break; + } + + case TokenType.Sequence: { + const events = on.assertSequence("Reference workflow on value"); + for (const eventToken of events) { + const event = eventToken.assertString(`Reference workflow on value ${eventToken}`).value; + if (event === "workflow_call") { + handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job)); + return; + } + } + break; + } + + case TokenType.Mapping: { + const eventMapping = on.assertMapping("Reference workflow on value"); + + for (const pair of eventMapping) { + const event = pair.key.assertString(`Reference workflow on value ${pair.key}`).value; + if (event !== "workflow_call") { + continue; + } + + if (pair.value.templateTokenType === TokenType.Null) { + handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job)); + return; + } + + const definitions = pair.value.assertMapping(`Reference workflow on value ${pair.key}`); + for (const definition of definitions) { + const definitionKey = definition.key.assertString(`on-workflow_call-${definition.key}`).value; + switch (definitionKey) { + case "inputs": + job["input-definitions"] = definition.value.assertMapping(`on-workflow_call-${definition.key}`); + break; + + case "outputs": + job.outputs = definition.value.assertMapping(`on-workflow_call-${definition.key}`); + break; + } + } + + handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job)); + return; + } + break; + } + } + + context.error(tokenForErrors, "workflow_call key is not defined in the referenced workflow."); +} diff --git a/actions-workflow-parser/src/model/converter/steps.ts b/actions-workflow-parser/src/model/converter/steps.ts index 2ecf0e7..aa11f5d 100644 --- a/actions-workflow-parser/src/model/converter/steps.ts +++ b/actions-workflow-parser/src/model/converter/steps.ts @@ -52,7 +52,7 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ let uses: StringToken | undefined; let continueOnError: boolean | undefined; let env: MappingToken | undefined; - const ifCondition = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, "success()"); + const ifCondition = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined); for (const item of mapping) { const key = item.key.assertString("steps item key"); switch (key.value) { diff --git a/actions-workflow-parser/src/model/type-guards.ts b/actions-workflow-parser/src/model/type-guards.ts index a1e09c0..60923f2 100644 --- a/actions-workflow-parser/src/model/type-guards.ts +++ b/actions-workflow-parser/src/model/type-guards.ts @@ -1,4 +1,4 @@ -import {ActionStep, RunStep, Step} from "./workflow-template"; +import {ActionStep, Job, ReusableWorkflowJob, RunStep, Step, WorkflowJob} from "./workflow-template"; export function isRunStep(step: Step): step is RunStep { return (step as RunStep).run !== undefined; @@ -7,3 +7,11 @@ export function isRunStep(step: Step): step is RunStep { export function isActionStep(step: Step): step is ActionStep { return (step as ActionStep).uses !== undefined; } + +export function isJob(job: WorkflowJob): job is Job { + return job.type === "job"; +} + +export function isReusableWorkflowJob(job: WorkflowJob): job is ReusableWorkflowJob { + return job.type === "reusableWorkflowJob"; +} diff --git a/actions-workflow-parser/src/model/workflow-template.ts b/actions-workflow-parser/src/model/workflow-template.ts index 2ede8f5..660fdea 100644 --- a/actions-workflow-parser/src/model/workflow-template.ts +++ b/actions-workflow-parser/src/model/workflow-template.ts @@ -9,7 +9,7 @@ import { export type WorkflowTemplate = { events: EventsConfig; - jobs: Job[]; + jobs: WorkflowJob[]; concurrency: TemplateToken; env: TemplateToken; @@ -28,23 +28,41 @@ export type ActionsEnvironmentReference = { url?: TemplateToken; }; -export type Job = { - type: string; +export type WorkflowJob = Job | ReusableWorkflowJob; + +export type JobType = "job" | "reusableWorkflowJob"; + +export type BaseJob = { + type: JobType; id: StringToken; name?: ScalarToken; needs?: StringToken[]; if: BasicExpressionToken; - env?: MappingToken; concurrency?: TemplateToken; - environment?: TemplateToken; strategy?: TemplateToken; + outputs?: MappingToken; +}; + +// `job-factory` in the schema +export type Job = BaseJob & { + type: "job"; + env?: MappingToken; + environment?: TemplateToken; "runs-on"?: TemplateToken; container?: TemplateToken; services?: TemplateToken; - outputs?: MappingToken; steps: Step[]; }; +// `workflow-job` in the schema +export type ReusableWorkflowJob = BaseJob & { + type: "reusableWorkflowJob"; + ref: StringToken; + "input-definitions"?: MappingToken; + "input-values"?: MappingToken; + jobs?: WorkflowJob[]; +}; + export type Container = { image: StringToken; credentials?: Credential; diff --git a/actions-workflow-parser/src/templates/template-reader.ts b/actions-workflow-parser/src/templates/template-reader.ts index 2bbcbbc..e44a263 100644 --- a/actions-workflow-parser/src/templates/template-reader.ts +++ b/actions-workflow-parser/src/templates/template-reader.ts @@ -616,10 +616,10 @@ class TemplateReader { tr: TokenRange, rawExpression: string, allowedContext: string[], - token: TemplateToken, + token: StringToken, definitionInfo: DefinitionInfo | undefined ): ExpressionToken | undefined { - const parseExpressionResult = this.parseExpression(tr, rawExpression, allowedContext, definitionInfo); + const parseExpressionResult = this.parseExpression(tr, token, rawExpression, allowedContext, definitionInfo); // Check for error if (parseExpressionResult.error) { @@ -631,7 +631,8 @@ class TemplateReader { } private parseExpression( - range: TokenRange | undefined, + range: TokenRange, + token: StringToken, value: string, allowedContext: string[], definitionInfo: DefinitionInfo | undefined @@ -666,9 +667,31 @@ class TemplateReader { }; } + const startTrim = value.length - value.trimStart().length; + const endTrim = value.length - value.trimEnd().length; + + const expressionRange: TokenRange = { + start: { + ...range.start, + column: range.start.column + OPEN_EXPRESSION.length + startTrim + }, + end: { + ...range.end, + column: range.end.column - CLOSE_EXPRESSION.length - endTrim + } + }; + // Return the expression return { - expression: new BasicExpressionToken(this._fileId, range, trimmed, definitionInfo, undefined, value), + expression: new BasicExpressionToken( + this._fileId, + range, + trimmed, + definitionInfo, + undefined, + token.source, + expressionRange + ), error: undefined }; } diff --git a/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts b/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts index b7c29f5..89295fd 100644 --- a/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts +++ b/actions-workflow-parser/src/templates/tokens/basic-expression-token.ts @@ -11,10 +11,20 @@ import {TokenType} from "./types"; export class BasicExpressionToken extends ExpressionToken { private readonly expr: string; + public readonly source: string | undefined; + public readonly originalExpressions: BasicExpressionToken[] | undefined; public readonly source: string; + /** + * The range of the expression within the source string. + * + * `range` is the range of the entire expression, including the `${{` and `}}`. `expression` is only the expression + * without any ${{ }} markers. `expressionRange` is the range of just the expression within the document. + */ + public readonly expressionRange: TokenRange | undefined; + /** * @param originalExpressions If the basic expression was transformed from individual expressions, these will be the original ones */ @@ -24,12 +34,14 @@ export class BasicExpressionToken extends ExpressionToken { expression: string, definitionInfo: DefinitionInfo | undefined, originalExpressions: BasicExpressionToken[] | undefined, - source: string + source: string | undefined, + expressionRange?: TokenRange | undefined ) { super(TokenType.BasicExpression, file, range, undefined, definitionInfo); this.expr = expression; - this.originalExpressions = originalExpressions; this.source = source; + this.originalExpressions = originalExpressions; + this.expressionRange = expressionRange; } public get expression(): string { @@ -44,7 +56,8 @@ export class BasicExpressionToken extends ExpressionToken { this.expr, this.definitionInfo, this.originalExpressions, - this.source + this.source, + this.expressionRange ) : new BasicExpressionToken( this.file, @@ -52,7 +65,8 @@ export class BasicExpressionToken extends ExpressionToken { this.expr, this.definitionInfo, this.originalExpressions, - this.source + this.source, + this.expressionRange ); } diff --git a/actions-workflow-parser/src/templates/tokens/template-token.test.ts b/actions-workflow-parser/src/templates/tokens/template-token.test.ts index 520eb28..34afdab 100644 --- a/actions-workflow-parser/src/templates/tokens/template-token.test.ts +++ b/actions-workflow-parser/src/templates/tokens/template-token.test.ts @@ -6,13 +6,10 @@ import {TemplateToken} from "./template-token"; describe("traverse", () => { it("returns parent token and key", () => { const workflow = parseWorkflow( - "wf.yaml", - [ - { - name: "wf.yaml", - content: `on: push` - } - ], + { + name: "wf.yaml", + content: `on: push` + }, nullTrace ); diff --git a/actions-workflow-parser/src/templates/tokens/token-range.ts b/actions-workflow-parser/src/templates/tokens/token-range.ts index 03d5f8e..8cc0c2b 100644 --- a/actions-workflow-parser/src/templates/tokens/token-range.ts +++ b/actions-workflow-parser/src/templates/tokens/token-range.ts @@ -2,6 +2,7 @@ export type Position = { /** The one-based line value */ line: number; + /** The one-based column value */ column: number; }; diff --git a/actions-workflow-parser/src/workflows/file-provider.ts b/actions-workflow-parser/src/workflows/file-provider.ts new file mode 100644 index 0000000..097d464 --- /dev/null +++ b/actions-workflow-parser/src/workflows/file-provider.ts @@ -0,0 +1,6 @@ +import {File} from "./file"; +import {FileReference} from "./file-reference"; + +export interface FileProvider { + getFileContent(ref: FileReference): Promise; +} diff --git a/actions-workflow-parser/src/workflows/file-reference.test.ts b/actions-workflow-parser/src/workflows/file-reference.test.ts new file mode 100644 index 0000000..f1cff01 --- /dev/null +++ b/actions-workflow-parser/src/workflows/file-reference.test.ts @@ -0,0 +1,55 @@ +import {parseFileReference} from "./file-reference"; + +describe("parseFileReference", () => { + it("parses local file reference", () => { + const ref = parseFileReference("./workflow/path"); + expect(ref).toEqual({ + path: "workflow/path" + }); + }); + + it("parses local file references with an empty path", () => { + const ref = parseFileReference("./"); + expect(ref).toEqual({ + path: "" + }); + }); + + it("parses remote file reference", () => { + const ref = parseFileReference("owner/repo/path@version"); + expect(ref).toEqual({ + owner: "owner", + repository: "repo", + path: "path", + version: "version" + }); + }); + + it("parses remote file reference with an empty path", () => { + const ref = parseFileReference("owner/repo@version"); + expect(ref).toEqual({ + owner: "owner", + repository: "repo", + path: "", + version: "version" + }); + }); + + it("parses remote file reference with slashes in the version", () => { + const ref = parseFileReference("owner/repo@feature-branch/dev"); + expect(ref).toEqual({ + owner: "owner", + repository: "repo", + path: "", + version: "feature-branch/dev" + }); + }); + + it("throws for malformed remote file references", () => { + expect(() => parseFileReference("owner/repo/path")).toThrowError("Invalid file reference: owner/repo/path"); + + expect(() => parseFileReference("owner/repo/path@")).toThrowError("Invalid file reference: owner/repo/path@"); + + expect(() => parseFileReference("owner@")).toThrowError("Invalid file reference: owner@"); + }); +}); diff --git a/actions-workflow-parser/src/workflows/file-reference.ts b/actions-workflow-parser/src/workflows/file-reference.ts new file mode 100644 index 0000000..3b51ca0 --- /dev/null +++ b/actions-workflow-parser/src/workflows/file-reference.ts @@ -0,0 +1,42 @@ +export type FileReference = LocalFileReference | RemoteFileReference; + +export type LocalFileReference = { + path: string; +}; + +export type RemoteFileReference = { + repository: string; + owner: string; + path: string; + version: string; +}; + +export function parseFileReference(ref: string): FileReference { + if (ref.startsWith("./")) { + return { + path: ref.substring(2) + }; + } + + const [remotePath, version] = ref.split("@"); + const [owner, repository, ...pathSegments] = remotePath.split("/").filter(s => s.length > 0); + + if (!owner || !repository || !version) { + throw new Error(`Invalid file reference: ${ref}`); + } + + return { + repository, + owner, + path: pathSegments.join("/"), + version + }; +} + +export function fileIdentifier(ref: FileReference): string { + if (!("repository" in ref)) { + return "./" + ref.path; + } + + return `${ref.owner}/${ref.repository}/${ref.path}@${ref.version}`; +} diff --git a/actions-workflow-parser/src/workflows/workflow-parser.test.ts b/actions-workflow-parser/src/workflows/workflow-parser.test.ts index 021ac2b..a4d587a 100644 --- a/actions-workflow-parser/src/workflows/workflow-parser.test.ts +++ b/actions-workflow-parser/src/workflows/workflow-parser.test.ts @@ -11,7 +11,7 @@ jobs: - name: 'Hello \${{ fromJSON('test') == inputs.name }}' run: echo Hello, world!`; - const result = parseWorkflow("main.yaml", [{name: "main.yaml", content: content}], nullTrace); + const result = parseWorkflow({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 index a49c903..ef32027 100644 --- a/actions-workflow-parser/src/workflows/workflow-parser.ts +++ b/actions-workflow-parser/src/workflows/workflow-parser.ts @@ -11,15 +11,16 @@ export interface ParseWorkflowResult { value: TemplateToken | undefined; } -export function parseWorkflow(entryFileName: string, files: File[], trace: TraceWriter): ParseWorkflowResult { - const context = new TemplateContext(new TemplateValidationErrors(), getWorkflowSchema(), trace); +export function parseWorkflow(entryFile: File, trace: TraceWriter): ParseWorkflowResult; +export function parseWorkflow(entryFile: File, context: TemplateContext): ParseWorkflowResult; +export function parseWorkflow(entryFile: File, contextOrTrace: TraceWriter | TemplateContext): ParseWorkflowResult { + const context = + contextOrTrace instanceof TemplateContext + ? contextOrTrace + : new TemplateContext(new TemplateValidationErrors(), getWorkflowSchema(), contextOrTrace); - // 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); + const fileId = context.getFileId(entryFile.name); + const reader = new YamlObjectReader(context, fileId, entryFile.content); if (context.errors.count > 0) { // The file is not valid YAML, template errors could be misleading return { diff --git a/actions-workflow-parser/src/workflows/yaml-object-reader.test.ts b/actions-workflow-parser/src/workflows/yaml-object-reader.test.ts index 4ff3b13..d4d4aac 100644 --- a/actions-workflow-parser/src/workflows/yaml-object-reader.test.ts +++ b/actions-workflow-parser/src/workflows/yaml-object-reader.test.ts @@ -81,13 +81,10 @@ it("YAML errors include range information", () => { function parseAsWorkflow(content: string): TemplateToken | undefined { const result = parseWorkflow( - "test.yaml", - [ - { - name: "test.yaml", - content: content - } - ], + { + name: "test.yaml", + content: content + }, nullTrace ); diff --git a/actions-workflow-parser/src/xlang.test.ts b/actions-workflow-parser/src/xlang.test.ts index 906ed63..e9e2ab1 100644 --- a/actions-workflow-parser/src/xlang.test.ts +++ b/actions-workflow-parser/src/xlang.test.ts @@ -3,6 +3,9 @@ import * as path from "path"; import * as YAML from "yaml"; import {convertWorkflowTemplate} from "./model/convert"; import {TraceWriter} from "./templates/trace-writer"; +import {File} from "./workflows/file"; +import {FileProvider} from "./workflows/file-provider"; +import {fileIdentifier, FileReference} from "./workflows/file-reference"; import {parseWorkflow} from "./workflows/workflow-parser"; interface TestOptions { @@ -41,31 +44,53 @@ describe("x-lang tests", () => { 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 + const test = async () => { + const testFileName = ".github/workflows" + fileName.substring(fileName.lastIndexOf("/")); + const testInput = testDocs[1]; + const expectedTemplate = testDocs[testDocs.length - 1].trim(); + + // For reusable workflow tests, additional workflows are passed in as pairs of + // file names and file contents + const reusableWorkflows: Record = {}; if (fileName.indexOf("reusable") !== -1) { - testFileName = testDocs[1]; - testInput = testDocs[2]; - expectedTemplate = testDocs[3].trim(); + for (let i = 2; i < testDocs.length - 1; i = i + 2) { + reusableWorkflows[testDocs[i]] = { + name: testDocs[i], + content: testDocs[i + 1] + }; + } } - const parseResult = parseWorkflow( - testFileName, - [ - { - name: testFileName, - content: testInput + const testFileProvider: FileProvider = { + getFileContent: async (ref: FileReference) => { + const file = reusableWorkflows[fileIdentifier(ref)]; + if (file) { + return file; } - ], + + throw new Error("File not found: " + fileName); + } + }; + + const parseResult = parseWorkflow( + { + name: testFileName, + content: testInput + }, nullTrace ); expect(parseResult.value).not.toBeUndefined(); - const workflowTemplate = convertWorkflowTemplate(parseResult.context, parseResult.value!); + const workflowTemplate = await convertWorkflowTemplate( + parseResult.context, + parseResult.value!, + undefined, + testFileProvider, + { + fetchReusableWorkflowDepth: 1 + } + ); // Unless this tests is only used by TypeScript, remove the events for now. // TODO: Remove this once we parse events everywhere diff --git a/actions-workflow-parser/testdata/reader/concurrency.yml b/actions-workflow-parser/testdata/reader/concurrency.yml index ad07011..b230bef 100644 --- a/actions-workflow-parser/testdata/reader/concurrency.yml +++ b/actions-workflow-parser/testdata/reader/concurrency.yml @@ -3,7 +3,7 @@ skip: - TypeScript --- on: push -concurrency: +concurrency: group: ${{ github.ref }} cancel-in-progress: true jobs: @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v3 - run: echo hi continue-on-error: true - concurrency: + concurrency: group: ${{ github.ref }} cancel-in-progress: true build2: @@ -24,7 +24,7 @@ jobs: concurrency: staging build4: runs-on: macos-latest - concurrency: + concurrency: group: ref cancel-in-progress: ${{ github.ref }} diff --git a/actions-workflow-parser/testdata/reader/errors-insert.yml b/actions-workflow-parser/testdata/reader/errors-insert.yml new file mode 100644 index 0000000..6506202 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-insert.yml @@ -0,0 +1,23 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + run: + runs-on: ubuntu-latest + env: + bad_insert1: "This is a bad ${{ insert }}" + bad_insert2: "${{ insert }} are bad at the beginning" + ${{ insert }}: ${{ github.ref }} + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-insert.yml (Line: 6, Col: 20): The directive 'insert' 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." + }, + { + "Message": ".github/workflows/errors-insert.yml (Line: 7, Col: 20): The directive 'insert' 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." + } + ] +} diff --git a/actions-workflow-parser/testdata/reader/errors-invalid-mapping-key.yml b/actions-workflow-parser/testdata/reader/errors-invalid-mapping-key.yml new file mode 100644 index 0000000..1611363 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-invalid-mapping-key.yml @@ -0,0 +1,19 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - null : ' ' + run: echo {{ 😀 }} +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-invalid-mapping-key.yml (Line: 6, Col: 9): Unexpected value ''" + } + ] +} \ No newline at end of file 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 index 2b35981..1c8e3c7 100644 --- 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 @@ -15,7 +15,7 @@ jobs: steps: - run: echo hi build3: - runs-on: + runs-on: group: ent/ steps: - run: echo hi diff --git a/actions-workflow-parser/testdata/reader/errors-matrix-bad-key.yml b/actions-workflow-parser/testdata/reader/errors-matrix-bad-key.yml new file mode 100644 index 0000000..164ca3a --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-matrix-bad-key.yml @@ -0,0 +1,19 @@ +include-source: false # Drop file/line/col from output +--- +on: push +jobs: + bad-strategy-key: + strategy: + bad-key: + os: [10] + runs-on: macos-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-matrix-bad-key.yml (Line: 5, Col: 7): Unexpected value 'bad-key'" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-matrix-empty-vector.yml b/actions-workflow-parser/testdata/reader/errors-matrix-empty-vector.yml new file mode 100644 index 0000000..6c4a744 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/errors-matrix-empty-vector.yml @@ -0,0 +1,22 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + empty-vector: + strategy: + matrix: + os: [] + version: [10,12] + runs-on: macos-latest + steps: + - run: echo hi +--- +{ + "errors": [ + { + "Message": ".github/workflows/errors-matrix-empty-vector.yml (Line: 6, Col: 13): Matrix vector 'os' does not contain any values" + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/errors-max-depth.yml b/actions-workflow-parser/testdata/reader/errors-max-depth.yml index 539fbee..7076f8d 100644 --- a/actions-workflow-parser/testdata/reader/errors-max-depth.yml +++ b/actions-workflow-parser/testdata/reader/errors-max-depth.yml @@ -1,7 +1,7 @@ include-source: false # Drop file/line/col from output -max-depth: 5 skip: - TypeScript +max-depth: 5 --- on: push jobs: diff --git a/actions-workflow-parser/testdata/reader/errors-max-file-size.yml b/actions-workflow-parser/testdata/reader/errors-max-file-size.yml index ec56b7b..832bf53 100644 --- a/actions-workflow-parser/testdata/reader/errors-max-file-size.yml +++ b/actions-workflow-parser/testdata/reader/errors-max-file-size.yml @@ -1,7 +1,7 @@ include-source: false # Drop file/line/col from output -max-file-size: 124 skip: - TypeScript +max-file-size: 124 --- on: push 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 index 67a93c7..82306fa 100644 --- a/actions-workflow-parser/testdata/reader/errors-max-result-size.yml +++ b/actions-workflow-parser/testdata/reader/errors-max-result-size.yml @@ -1,7 +1,7 @@ include-source: false # Drop file/line/col from output -max-result-size: 768 skip: - TypeScript +max-result-size: 768 --- on: push jobs: 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 index df6d826..6f1d9fb 100644 --- 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 @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: 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 index 7611a4c..87c5b82 100644 --- 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 @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: 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 index b812f32..dac0d5a 100644 --- a/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets.yml +++ b/actions-workflow-parser/testdata/reader/errors-reusable-workflow-job-secrets.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: 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 index 1b32531..567a3e4 100644 --- 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 @@ -1,8 +1,8 @@ include-source: false # Drop file/line/col from output max-result-size: 2048 skip: - - TypeScript - Go + - TypeScript --- on: push jobs: diff --git a/actions-workflow-parser/testdata/reader/escape-html-values.yml b/actions-workflow-parser/testdata/reader/escape-html-values.yml index 5138f67..2fb679a 100644 --- a/actions-workflow-parser/testdata/reader/escape-html-values.yml +++ b/actions-workflow-parser/testdata/reader/escape-html-values.yml @@ -12,7 +12,7 @@ jobs: steps: - run: echo Hello && World #string token build2: - if: false + if: false runs-on: ubuntu-latest steps: - run: echo 1 diff --git a/actions-workflow-parser/testdata/reader/float-folded-style.yml b/actions-workflow-parser/testdata/reader/float-folded-style.yml new file mode 100644 index 0000000..bd28b60 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/float-folded-style.yml @@ -0,0 +1,49 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Switch to using Python 3.10 by default + uses: actions/setup-python@v4 + with: + python-version: >- + 3.10 +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__actions_setup-python", + "name": "Switch to using Python 3.10 by default", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/setup-python@v4", + "with": { + "type": 2, + "map": [ + { + "Key": "python-version", + "Value": "3.10" + } + ] + } + } + ] + } + ] +} \ No newline at end of file diff --git a/actions-workflow-parser/testdata/reader/insert.yml b/actions-workflow-parser/testdata/reader/insert.yml new file mode 100644 index 0000000..11f7d95 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/insert.yml @@ -0,0 +1,94 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + generate: + runs-on: ubuntu-latest + outputs: + matrix_map: ${{ steps.set-matrix.outputs.matrix }} + steps: + - run: echo hi + run: + runs-on: ubuntu-latest + needs: generate + env: + ${{ insert }}: ${{ fromJson(needs.generate.outputs.matrix_map) }} + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "generate", + "name": "generate", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "outputs": { + "type": 2, + "map": [ + { + "Key": "matrix_map", + "Value": { + "type": 3, + "expr": "steps.set-matrix.outputs.matrix" + } + } + ] + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "run", + "name": "run", + "needs": [ + "generate" + ], + "if": { + "type": 3, + "expr": "success()" + }, + "env": { + "type": 2, + "map": [ + { + "Key": { + "type": 4, + "directive": "insert" + }, + "Value": { + "type": 3, + "expr": "fromJson(needs.generate.outputs.matrix_map)" + } + } + ] + }, + "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 index bfa5a5b..ef715cf 100644 --- a/actions-workflow-parser/testdata/reader/job-if.yml +++ b/actions-workflow-parser/testdata/reader/job-if.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - run: echo hi - build4: + build4: needs: - build - build2 diff --git a/actions-workflow-parser/testdata/reader/matrix-basic.yml b/actions-workflow-parser/testdata/reader/matrix-basic.yml new file mode 100644 index 0000000..d6f1e91 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/matrix-basic.yml @@ -0,0 +1,285 @@ +include-source: false # Drop file/line/col from output +skip: + - TypeScript +--- +on: push +jobs: + matrix-basic: + strategy: + matrix: + version: [10, 12, 14] + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - run: echo hi + matrix-nested-sequences: + strategy: + matrix: + version: [[[[10]],2],12,14] + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - run: echo hi + matrix-with-infinity: + strategy: + matrix: + version: [1, 999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999] + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - run: echo hi + nested-matrix: + strategy: + matrix: { vector1: [ {foo: {bar: baz} } ] } + runs-on: linux + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "job", + "id": "matrix-basic", + "name": "matrix-basic", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "version", + "Value": { + "type": 1, + "seq": [ + 10, + 12, + 14 + ] + } + }, + { + "Key": "os", + "Value": { + "type": 1, + "seq": [ + "ubuntu-latest", + "windows-latest" + ] + } + } + ] + } + } + ] + }, + "runs-on": { + "type": 3, + "expr": "matrix.os" + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "matrix-nested-sequences", + "name": "matrix-nested-sequences", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "version", + "Value": { + "type": 1, + "seq": [ + { + "type": 1, + "seq": [ + { + "type": 1, + "seq": [ + { + "type": 1, + "seq": [ + 10 + ] + } + ] + }, + 2 + ] + }, + 12, + 14 + ] + } + }, + { + "Key": "os", + "Value": { + "type": 1, + "seq": [ + "ubuntu-latest", + "windows-latest" + ] + } + } + ] + } + } + ] + }, + "runs-on": { + "type": 3, + "expr": "matrix.os" + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "matrix-with-infinity", + "name": "matrix-with-infinity", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "version", + "Value": { + "type": 1, + "seq": [ + 1, + "Infinity" + ] + } + }, + { + "Key": "os", + "Value": { + "type": 1, + "seq": [ + "ubuntu-latest", + "windows-latest" + ] + } + } + ] + } + } + ] + }, + "runs-on": { + "type": 3, + "expr": "matrix.os" + }, + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + }, + { + "type": "job", + "id": "nested-matrix", + "name": "nested-matrix", + "if": { + "type": 3, + "expr": "success()" + }, + "strategy": { + "type": 2, + "map": [ + { + "Key": "matrix", + "Value": { + "type": 2, + "map": [ + { + "Key": "vector1", + "Value": { + "type": 1, + "seq": [ + { + "type": 2, + "map": [ + { + "Key": "foo", + "Value": { + "type": 2, + "map": [ + { + "Key": "bar", + "Value": "baz" + } + ] + } + } + ] + } + ] + } + } + ] + } + } + ] + }, + "runs-on": "linux", + "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/misc-jobs.yml b/actions-workflow-parser/testdata/reader/misc-jobs.yml index ca88646..417c527 100644 --- a/actions-workflow-parser/testdata/reader/misc-jobs.yml +++ b/actions-workflow-parser/testdata/reader/misc-jobs.yml @@ -10,7 +10,7 @@ jobs: name: ${{ github.actor }} timeout-minutes: ${{ github.ref }} cancel-timeout-minutes: 300 - concurrency: + concurrency: group: ${{ github.ref }} cancel-in-progress: true env: @@ -28,10 +28,10 @@ jobs: build2: runs-on: [self-hosted, linux] continue-on-error: true - name: Jobs Repro Hardcode + name: Jobs Repro Hardcode timeout-minutes: 360 cancel-timeout-minutes: 300 - concurrency: + concurrency: group: groupA cancel-in-progress: true env: 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 index c1a963d..f65c6b2 100644 --- a/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs-null.yml +++ b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs-null.yml @@ -1,6 +1,6 @@ include-source: false # Drop file/line/col from output skip: - - Typescript + - TypeScript --- on: workflow_dispatch: diff --git a/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs.yml b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs.yml index 56920d2..b97e1f1 100644 --- a/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs.yml +++ b/actions-workflow-parser/testdata/reader/on-workflow_dispatch-inputs.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - - Go - - Typescript + - TypeScript --- on: workflow_dispatch: @@ -22,6 +21,10 @@ on: - debug # Defaults to string input4: + input5-Env: + description: 'Test environment' + type: environment + required: true jobs: my-job: runs-on: ubuntu-latest @@ -33,7 +36,8 @@ jobs: "input1": "string", "input2": "boolean", "input3": "choice", - "input4": "string" + "input4": "string", + "input5-Env": "environment" }, "jobs": [ { 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 index 2b53084..ee2019a 100644 --- 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 @@ -1,6 +1,6 @@ include-source: false # Drop file/line/col from output skip: - - Typescript + - TypeScript --- on: workflow_dispatch: diff --git a/actions-workflow-parser/testdata/reader/permissions.yml b/actions-workflow-parser/testdata/reader/permissions.yml index 54ba0de..df6f689 100644 --- a/actions-workflow-parser/testdata/reader/permissions.yml +++ b/actions-workflow-parser/testdata/reader/permissions.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v3 - run: echo hi continue-on-error: true - deploy: + deploy: runs-on: ubuntu-latest permissions: actions: write diff --git a/actions-workflow-parser/testdata/reader/preserves-source-info-basic.yml b/actions-workflow-parser/testdata/reader/preserves-source-info-basic.yml index 9063438..9c16081 100644 --- a/actions-workflow-parser/testdata/reader/preserves-source-info-basic.yml +++ b/actions-workflow-parser/testdata/reader/preserves-source-info-basic.yml @@ -1,6 +1,5 @@ include-source: true # Preserve file/line/col in serialized output skip: - - Go - TypeScript --- on: push 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 index f7ed89e..acf4c70 100644 --- a/actions-workflow-parser/testdata/reader/preserves-source-info-reusable-workflow.yml +++ b/actions-workflow-parser/testdata/reader/preserves-source-info-reusable-workflow.yml @@ -33,46 +33,34 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": { + "type": "reusableWorkflowJob", + "id": { "type": 0, "file": 1, "line": 3, "col": 3, "lit": "build" }, - "Name": { + "name": { "type": 0, "file": 1, "line": 3, "col": 3, "lit": "build" }, - "Needs": [], - "If": { + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": { + "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": [ + "jobs": [ { "type": "job", "id": { @@ -120,46 +108,34 @@ jobs: ] }, { - "Type": "reusableWorkflowJob", - "Id": { + "type": "reusableWorkflowJob", + "id": { "type": 0, "file": 1, "line": 5, "col": 3, "lit": "deploy" }, - "Name": { + "name": { "type": 0, "file": 1, "line": 5, "col": 3, "lit": "deploy" }, - "Needs": [], - "If": { + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": { + "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": [ + "jobs": [ { "type": "job", "id": { diff --git a/actions-workflow-parser/testdata/reader/preserves-source-info-simple.yml b/actions-workflow-parser/testdata/reader/preserves-source-info-simple.yml index e7797df..75303ce 100644 --- a/actions-workflow-parser/testdata/reader/preserves-source-info-simple.yml +++ b/actions-workflow-parser/testdata/reader/preserves-source-info-simple.yml @@ -3,7 +3,7 @@ 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) +# 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 diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-inputs.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-inputs.yml index 0e209c6..3604796 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-inputs.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-inputs.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: @@ -53,17 +52,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "type": "reusableWorkflowJob", + "id": "build", + "name": "build", + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/build.yml@v1", - "Permissions": null, - "InputDefinitions": { + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "input-definitions": { "type": 2, "map": [ { @@ -200,7 +198,7 @@ jobs: } ] }, - "InputValues": { + "input-values": { "type": 2, "map": [ { @@ -229,16 +227,7 @@ jobs: } ] }, - "SecretDefinitions": null, - "SecretValues": null, - "InheritSecrets": false, - "Outputs": null, - "Defaults": null, - "Env": null, - "Concurrency": null, - "EmbeddedConcurrency": null, - "Strategy": null, - "Jobs": [ + "jobs": [ { "type": "job", "id": "deploy", diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-basic.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-basic.yml index b4721ab..9d9c070 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-job-basic.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-basic.yml @@ -66,19 +66,18 @@ jobs: ] }, { - "Type": "reusableWorkflowJob", - "Id": "deploy-1", - "Name": "deploy-1", - "Needs": [ + "type": "reusableWorkflowJob", + "id": "deploy-1", + "name": "deploy-1", + "needs": [ "build" ], - "If": { + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/deploy.yml@v1", - "Permissions": null, - "InputDefinitions": { + "ref": "contoso/templates/.github/workflows/deploy.yml@v1", + "input-definitions": { "type": 2, "map": [ { @@ -99,7 +98,7 @@ jobs: } ] }, - "InputValues": { + "input-values": { "type": 2, "map": [ { @@ -108,7 +107,7 @@ jobs: } ] }, - "SecretDefinitions": { + "secret-definitions": { "type": 2, "map": [ { @@ -129,7 +128,7 @@ jobs: } ] }, - "SecretValues": { + "secret-values": { "type": 2, "map": [ { @@ -141,14 +140,7 @@ jobs: } ] }, - "InheritSecrets": false, - "Outputs": null, - "Defaults": null, - "Env": null, - "Concurrency": null, - "EmbeddedConcurrency": null, - "Strategy": null, - "Jobs": [ + "jobs": [ { "type": "job", "id": "job1", @@ -184,19 +176,18 @@ jobs: ] }, { - "Type": "reusableWorkflowJob", - "Id": "deploy-2", - "Name": "deploy-2", - "Needs": [ + "type": "reusableWorkflowJob", + "id": "deploy-2", + "name": "deploy-2", + "needs": [ "build" ], - "If": { + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/deploy.yml@v1", - "Permissions": null, - "InputDefinitions": { + "ref": "contoso/templates/.github/workflows/deploy.yml@v1", + "input-definitions": { "type": 2, "map": [ { @@ -217,7 +208,7 @@ jobs: } ] }, - "InputValues": { + "input-values": { "type": 2, "map": [ { @@ -226,7 +217,7 @@ jobs: } ] }, - "SecretDefinitions": { + "secret-definitions": { "type": 2, "map": [ { @@ -247,15 +238,8 @@ jobs: } ] }, - "SecretValues": null, - "InheritSecrets": true, - "Outputs": null, - "Defaults": null, - "Env": null, - "Concurrency": null, - "EmbeddedConcurrency": null, - "Strategy": null, - "Jobs": [ + "inherit-secrets": true, + "jobs": [ { "type": "job", "id": "job1", 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 index 31c89cb..0afcc09 100644 --- 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 @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: @@ -35,17 +34,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "deploy", - "Name": "deploy", - "Needs": [], - "If": { + "type": "reusableWorkflowJob", + "id": "deploy", + "name": "deploy", + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/deploy.yml@v1", - "Permissions": null, - "InputDefinitions": { + "ref": "contoso/templates/.github/workflows/deploy.yml@v1", + "input-definitions": { "type": 2, "map": [ { @@ -98,7 +96,7 @@ jobs: } ] }, - "InputValues": { + "input-values": { "type": 2, "map": [ { @@ -119,16 +117,7 @@ jobs: } ] }, - "SecretDefinitions": null, - "SecretValues": null, - "InheritSecrets": false, - "Outputs": null, - "Defaults": null, - "Env": null, - "Concurrency": null, - "EmbeddedConcurrency": null, - "Strategy": null, - "Jobs": [ + "jobs": [ { "type": "job", "id": "job1", diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml index 5b52215..3e67c56 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-name.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: @@ -26,28 +25,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "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": [ + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "jobs": [ { "type": "job", "id": "build-it", @@ -71,28 +58,16 @@ jobs: ] }, { - "Type": "reusableWorkflowJob", - "Id": "build1", - "Name": "custom name", - "Needs": [], - "If": { + "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": [ + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "jobs": [ { "type": "job", "id": "build-it", @@ -116,31 +91,19 @@ jobs: ] }, { - "Type": "reusableWorkflowJob", - "Id": "build2", - "Name": { + "type": "reusableWorkflowJob", + "id": "build2", + "name": { "type": 3, "expr": "github.ref" }, - "Needs": [], - "If": { + "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": [ + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "jobs": [ { "type": "job", "id": "build-it", 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 index 9ae18a1..ec93258 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-basic.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-basic.yml @@ -28,51 +28,27 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "deploy-level-0", - "Name": "deploy-level-0", - "Needs": [], - "If": { + "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": [ + "ref": "contoso/templates/.github/workflows/deploy-level-1.yml@v1", + "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "deploy-level-1", - "Name": "deploy-level-1", - "Needs": [], - "If": { + "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": [ + "ref": "contoso/templates/.github/workflows/deploy-level-2.yml@v1", + "jobs": [ { "type": "job", "id": "deploy-level-1", diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-multiple.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-multiple.yml new file mode 100644 index 0000000..de28ff1 --- /dev/null +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-multiple.yml @@ -0,0 +1,205 @@ +include-source: false # Drop file/line/col from output +skip: + - Go + - TypeScript +max-nested-reusable-workflows-depth: 2 +--- +on: push +jobs: + a: + uses: contoso/templates/.github/workflows/deploy-level-1a.yml@v1 + b: + uses: contoso/templates/.github/workflows/deploy-level-1b.yml@v1 + c: + uses: contoso/templates/.github/workflows/deploy-level-1c.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-1a.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-1a: + uses: contoso/templates/.github/workflows/deploy-level-2a.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-2a.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-2a: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +contoso/templates/.github/workflows/deploy-level-1b.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-1b: + uses: contoso/templates/.github/workflows/deploy-level-2b.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-2b.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-2b: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +contoso/templates/.github/workflows/deploy-level-1c.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-1c: + uses: contoso/templates/.github/workflows/deploy-level-2c.yml@v1 +--- +contoso/templates/.github/workflows/deploy-level-2c.yml@v1 +--- +on: workflow_call +jobs: + deploy-level-2c: + runs-on: ubuntu-latest + steps: + - run: echo hi +--- +{ + "jobs": [ + { + "type": "reusableWorkflowJob", + "id": "a", + "name": "a", + "needs": [], + "if": { + "type": 3, + "expr": "success()" + }, + "ref": "contoso/templates/.github/workflows/deploy-level-1a.yml@v1", + "jobs": [ + { + "type": "reusableWorkflowJob", + "id": "deploy-level-1a", + "name": "deploy-level-1a", + "needs": [], + "if": { + "type": 3, + "expr": "success()" + }, + "ref": "contoso/templates/.github/workflows/deploy-level-2a.yml@v1", + "jobs": [ + { + "type": "job", + "id": "deploy-level-2a", + "name": "deploy-level-2a", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] + } + ] + }, + { + "type": "reusableWorkflowJob", + "id": "b", + "name": "b", + "needs": [], + "if": { + "type": 3, + "expr": "success()" + }, + "ref": "contoso/templates/.github/workflows/deploy-level-1b.yml@v1", + "jobs": [ + { + "type": "reusableWorkflowJob", + "id": "deploy-level-1b", + "name": "deploy-level-1b", + "needs": [], + "if": { + "type": 3, + "expr": "success()" + }, + "ref": "contoso/templates/.github/workflows/deploy-level-2b.yml@v1", + "jobs": [ + { + "type": "job", + "id": "deploy-level-2b", + "name": "deploy-level-2b", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "__run", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "echo hi" + } + ] + } + ] + } + ] + }, + { + "type": "reusableWorkflowJob", + "id": "c", + "name": "c", + "needs": [], + "if": { + "type": 3, + "expr": "success()" + }, + "ref": "contoso/templates/.github/workflows/deploy-level-1c.yml@v1", + "jobs": [ + { + "type": "reusableWorkflowJob", + "id": "deploy-level-1c", + "name": "deploy-level-1c", + "needs": [], + "if": { + "type": 3, + "expr": "success()" + }, + "ref": "contoso/templates/.github/workflows/deploy-level-2c.yml@v1", + "jobs": [ + { + "type": "job", + "id": "deploy-level-2c", + "name": "deploy-level-2c", + "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 index f8b78fd..773dd93 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-permissions.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-nested-permissions.yml @@ -42,80 +42,47 @@ jobs: }, "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "deploy-level-0", - "Name": "deploy-level-0", - "Needs": [], - "If": { + "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": { + "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": [ + "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "deploy-level-1", - "Name": "deploy-level-1", - "Needs": [], - "If": { + "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": { + "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": [ + "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "deploy-level-3", - "Name": "deploy-level-3", - "Needs": [], - "If": { + "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": { + "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": [ + "jobs": [ { "type": "job", "id": "deploy-level-3", 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 index 58a2555..e613903 100644 --- 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 @@ -47,30 +47,19 @@ permissions: }, "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "type": "reusableWorkflowJob", + "id": "build", + "name": "build", + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/build.yml@v1", - "Permissions": { + "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": [ + "jobs": [ { "type": "job", "id": "deploy", 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 index b0d0b14..47ce8d1 100644 --- 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 @@ -47,30 +47,19 @@ jobs: }, "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "type": "reusableWorkflowJob", + "id": "build", + "name": "build", + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/build.yml@v1", - "Permissions": { + "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": [ + "jobs": [ { "type": "job", "id": "deploy", 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 index 4eaca32..d6fb3b6 100644 --- 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 @@ -27,28 +27,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "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": [ + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "jobs": [ { "type": "job", "id": "deploy", @@ -72,30 +60,19 @@ jobs: ] }, { - "Type": "reusableWorkflowJob", - "Id": "build2", - "Name": "build2", - "Needs": [], - "If": { + "type": "reusableWorkflowJob", + "id": "build2", + "name": "build2", + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/build.yml@v1", - "Permissions": { + "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": [ + "jobs": [ { "type": "job", "id": "deploy", 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 index b9a0e5d..e9fa67a 100644 --- 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 @@ -27,28 +27,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "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": [ + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "jobs": [ { "type": "job", "id": "deploy", @@ -72,30 +60,19 @@ jobs: ] }, { - "Type": "reusableWorkflowJob", - "Id": "build2", - "Name": "build2", - "Needs": [], - "If": { + "type": "reusableWorkflowJob", + "id": "build2", + "name": "build2", + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/build.yml@v1", - "Permissions": { + "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": [ + "jobs": [ { "type": "job", "id": "deploy", 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 index 6833b2f..7f096d0 100644 --- 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 @@ -32,30 +32,19 @@ jobs: }, "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "type": "reusableWorkflowJob", + "id": "build", + "name": "build", + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/build.yml@v1", - "Permissions": { + "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": [ + "jobs": [ { "type": "job", "id": "deploy", @@ -82,30 +71,19 @@ jobs: ] }, { - "Type": "reusableWorkflowJob", - "Id": "build2", - "Name": "build2", - "Needs": [], - "If": { + "type": "reusableWorkflowJob", + "id": "build2", + "name": "build2", + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/build.yml@v1", - "Permissions": { + "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": [ + "jobs": [ { "type": "job", "id": "deploy", diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-job-strategy.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-job-strategy.yml index adbe218..c343684 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-job-strategy.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-job-strategy.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: @@ -31,17 +30,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "type": "reusableWorkflowJob", + "id": "build", + "name": "build", + "needs": [], + "if": { "type": 3, "expr": "success()" }, - "Ref": "contoso/templates/.github/workflows/build.yml@v1", - "Permissions": null, - "InputDefinitions": { + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "input-definitions": { "type": 2, "map": [ { @@ -58,7 +56,7 @@ jobs: } ] }, - "InputValues": { + "input-values": { "type": 2, "map": [ { @@ -70,15 +68,7 @@ jobs: } ] }, - "SecretDefinitions": null, - "SecretValues": null, - "InheritSecrets": false, - "Outputs": null, - "Defaults": null, - "Env": null, - "Concurrency": null, - "EmbeddedConcurrency": null, - "Strategy": { + "strategy": { "type": 2, "map": [ { @@ -101,7 +91,7 @@ jobs: } ] }, - "Jobs": [ + "jobs": [ { "type": "job", "id": "build-it", diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml index 4b4a31c..d129cdb 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-multiple.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: @@ -33,28 +32,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "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": [ + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "jobs": [ { "type": "job", "id": "deploy", @@ -78,28 +65,16 @@ jobs: ] }, { - "Type": "reusableWorkflowJob", - "Id": "build2", - "Name": "build2", - "Needs": [], - "If": { + "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": [ + "ref": "contoso2/templates2/.github/workflows/build2.yml@v1", + "jobs": [ { "type": "job", "id": "deploy2", diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml index 5550cd7..e07bb6b 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-no-inputs.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: @@ -21,28 +20,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "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": [ + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "jobs": [ { "type": "job", "id": "deploy", diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-outputs.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-outputs.yml index 6ef098c..62501c2 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-outputs.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-outputs.yml @@ -1,7 +1,6 @@ include-source: false # Drop file/line/col from output skip: - Go - - TypeScript --- on: push jobs: @@ -27,22 +26,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "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": { + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "outputs": { "type": 2, "map": [ { @@ -66,12 +59,7 @@ jobs: } ] }, - "Defaults": null, - "Env": null, - "Concurrency": null, - "EmbeddedConcurrency": null, - "Strategy": null, - "Jobs": [ + "jobs": [ { "type": "job", "id": "job1", 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 index 883d3d6..2c8e078 100644 --- 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 @@ -25,19 +25,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "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": { + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "secret-definitions": { "type": 2, "map": [ { @@ -46,7 +43,7 @@ jobs: } ] }, - "SecretValues": { + "secret-values": { "type": 2, "map": [ { @@ -58,14 +55,7 @@ jobs: } ] }, - "InheritSecrets": false, - "Outputs": null, - "Defaults": null, - "Env": null, - "Concurrency": null, - "EmbeddedConcurrency": null, - "Strategy": null, - "Jobs": [ + "jobs": [ { "type": "job", "id": "deploy", diff --git a/actions-workflow-parser/testdata/reader/reusable-workflow-secrets.yml b/actions-workflow-parser/testdata/reader/reusable-workflow-secrets.yml index 40cda81..7514011 100644 --- a/actions-workflow-parser/testdata/reader/reusable-workflow-secrets.yml +++ b/actions-workflow-parser/testdata/reader/reusable-workflow-secrets.yml @@ -32,19 +32,16 @@ jobs: { "jobs": [ { - "Type": "reusableWorkflowJob", - "Id": "build", - "Name": "build", - "Needs": [], - "If": { + "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": { + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "secret-definitions": { "type": 2, "map": [ { @@ -61,7 +58,7 @@ jobs: } ] }, - "SecretValues": { + "secret-values": { "type": 2, "map": [ { @@ -73,14 +70,7 @@ jobs: } ] }, - "InheritSecrets": false, - "Outputs": null, - "Defaults": null, - "Env": null, - "Concurrency": null, - "EmbeddedConcurrency": null, - "Strategy": null, - "Jobs": [ + "jobs": [ { "type": "job", "id": "deploy", @@ -104,19 +94,16 @@ jobs: ] }, { - "Type": "reusableWorkflowJob", - "Id": "build2", - "Name": "build2", - "Needs": [], - "If": { + "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": { + "ref": "contoso/templates/.github/workflows/build.yml@v1", + "secret-definitions": { "type": 2, "map": [ { @@ -133,15 +120,8 @@ jobs: } ] }, - "SecretValues": null, - "InheritSecrets": true, - "Outputs": null, - "Defaults": null, - "Env": null, - "Concurrency": null, - "EmbeddedConcurrency": null, - "Strategy": null, - "Jobs": [ + "inherit-secrets": true, + "jobs": [ { "type": "job", "id": "deploy", 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 index 0713335..f588b88 100644 --- 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 @@ -6,10 +6,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + - uses: Actions/Capitalized@v2 - uses: actions/setup-node@v2 - uses: docker://alpine:latest - run: echo hello - uses: actions/checkout@v2 + - uses: Actions/Capitalized@v2 - uses: actions/setup-node@v3 - uses: docker://alpine:latest - run: echo hello @@ -34,6 +36,14 @@ jobs: }, "uses": "actions/checkout@v2" }, + { + "id": "__Actions_Capitalized", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "Actions/Capitalized@v2" + }, { "id": "__actions_setup-node", "if": { @@ -66,6 +76,14 @@ jobs: }, "uses": "actions/checkout@v2" }, + { + "id": "__Actions_Capitalized_2", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "Actions/Capitalized@v2" + }, { "id": "__actions_setup-node_2", "if": { diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-bool.yml b/actions-workflow-parser/testdata/reader/yaml-schema-bool.yml index b0bbd06..9100be9 100644 --- a/actions-workflow-parser/testdata/reader/yaml-schema-bool.yml +++ b/actions-workflow-parser/testdata/reader/yaml-schema-bool.yml @@ -1,6 +1,4 @@ include-source: false # Drop file/line/col from output -skip: - - Go --- on: push jobs: diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-exceptions.yml b/actions-workflow-parser/testdata/reader/yaml-schema-exceptions.yml new file mode 100644 index 0000000..70de47f --- /dev/null +++ b/actions-workflow-parser/testdata/reader/yaml-schema-exceptions.yml @@ -0,0 +1,77 @@ +include-source: false # Drop file/line/col from output +--- +on: push +env: + UNDERSCORED_INT_AS_STRING: 12_345 + UNDERSCORED_FLOAT_AS_STRING: 23_456.789_321 +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + vector-1: + - 12_345 + - 23_456.789_321 + steps: + - run: echo hi +--- +{ + "env": { + "type": 2, + "map": [ + { + "Key": "UNDERSCORED_INT_AS_STRING", + "Value": "12_345" + }, + { + "Key": "UNDERSCORED_FLOAT_AS_STRING", + "Value": "23_456.789_321" + } + ] + }, + "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": [ + "12_345", + "23_456.789_321" + ] + } + } + ] + } + } + ] + }, + "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 index 2531ab8..fb7f36f 100644 --- a/actions-workflow-parser/testdata/reader/yaml-schema-float.yml +++ b/actions-workflow-parser/testdata/reader/yaml-schema-float.yml @@ -1,6 +1,5 @@ include-source: false # Drop file/line/col from output skip: - - Go - TypeScript --- on: push diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-int.yml b/actions-workflow-parser/testdata/reader/yaml-schema-int.yml index c571fa3..a6189c4 100644 --- a/actions-workflow-parser/testdata/reader/yaml-schema-int.yml +++ b/actions-workflow-parser/testdata/reader/yaml-schema-int.yml @@ -1,6 +1,4 @@ include-source: false # Drop file/line/col from output -skip: - - Go --- on: push jobs: diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-null.yml b/actions-workflow-parser/testdata/reader/yaml-schema-null.yml index 86d09a0..6349637 100644 --- a/actions-workflow-parser/testdata/reader/yaml-schema-null.yml +++ b/actions-workflow-parser/testdata/reader/yaml-schema-null.yml @@ -1,6 +1,5 @@ include-source: false # Drop file/line/col from output skip: - - Go - TypeScript --- on: push diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-sequence.yml b/actions-workflow-parser/testdata/reader/yaml-schema-sequence.yml index c285c3e..8d458f7 100644 --- a/actions-workflow-parser/testdata/reader/yaml-schema-sequence.yml +++ b/actions-workflow-parser/testdata/reader/yaml-schema-sequence.yml @@ -1,6 +1,5 @@ include-source: false # Drop file/line/col from output skip: - - Go - TypeScript --- on: push 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 index e474ae6..026d4ef 100644 --- a/actions-workflow-parser/testdata/reader/yaml-schema-str-block-styles.yml +++ b/actions-workflow-parser/testdata/reader/yaml-schema-str-block-styles.yml @@ -1,6 +1,4 @@ include-source: false # Drop file/line/col from output -skip: - - Go --- on: push jobs: 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 index 092a72f..9f2a87c 100644 --- a/actions-workflow-parser/testdata/reader/yaml-schema-str-flow-styles.yml +++ b/actions-workflow-parser/testdata/reader/yaml-schema-str-flow-styles.yml @@ -1,6 +1,5 @@ include-source: false # Drop file/line/col from output skip: - - Go - TypeScript --- on: push @@ -35,7 +34,7 @@ jobs: # 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 + - "hello world" steps: diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-string.yml b/actions-workflow-parser/testdata/reader/yaml-schema-string.yml index 41ed76d..cc9ca28 100644 --- a/actions-workflow-parser/testdata/reader/yaml-schema-string.yml +++ b/actions-workflow-parser/testdata/reader/yaml-schema-string.yml @@ -1,6 +1,6 @@ include-source: false # Drop file/line/col from output skip: -- TypeScript + - TypeScript --- on: push jobs: @@ -12,6 +12,14 @@ jobs: dot: . single-quoted-number: '8' double-quoted-number: "9" + hex-number: 0xCB2431 + hex-string: "0xCB2431" + # binary-number: 0b10100111001 // Binary numbers are not supported by C# at the moment + # binary-string: "0b1111" + octal-number: 0o1234567 + octal-string: "0o1234567" + python-version: >- + 3.10 --- { "jobs": [ @@ -46,6 +54,26 @@ jobs: { "Key": "double-quoted-number", "Value": "9" + }, + { + "Key": "hex-number", + "Value": "13313073" + }, + { + "Key": "hex-string", + "Value": "0xCB2431" + }, + { + "Key": "octal-number", + "Value": "342391" + }, + { + "Key": "octal-string", + "Value": "0o1234567" + }, + { + "Key": "python-version", + "Value": "3.10" } ] } diff --git a/actions-workflow-parser/testdata/reader/yaml-schema-timestamp.yml b/actions-workflow-parser/testdata/reader/yaml-schema-timestamp.yml index 26ab729..c722fb9 100644 --- a/actions-workflow-parser/testdata/reader/yaml-schema-timestamp.yml +++ b/actions-workflow-parser/testdata/reader/yaml-schema-timestamp.yml @@ -1,6 +1,6 @@ include-source: false # Drop file/line/col from output skip: -- TypeScript + - TypeScript --- on: push jobs: diff --git a/browser-playground/.prettierignore b/browser-playground/.prettierignore new file mode 100644 index 0000000..ce98533 --- /dev/null +++ b/browser-playground/.prettierignore @@ -0,0 +1,5 @@ +node_modules +dist +*.md +*.js +*.json diff --git a/browser-playground/package.json b/browser-playground/package.json index d3d4494..0170c9a 100644 --- a/browser-playground/package.json +++ b/browser-playground/package.json @@ -1,12 +1,12 @@ { "name": "browser-playground", - "version": "0.1.113", + "version": "0.1.126", "description": "", "private": true, "main": "index.js", "type": "module", "dependencies": { - "@github/actions-languageserver": "^0.1.113", + "@github/actions-languageserver": "^0.1.126", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2", "monaco-languageclient": "^4.0.3", diff --git a/lerna.json b/lerna.json index d6d3e09..ca156aa 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useWorkspaces": true, - "version": "0.1.113" + "version": "0.1.126" } diff --git a/package-lock.json b/package-lock.json index 0ed4d8c..13332ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ }, "actions-expressions": { "name": "@github/actions-expressions", - "version": "0.1.113", + "version": "0.1.126", "license": "MIT", "devDependencies": { "@types/jest": "^29.0.3", @@ -34,11 +34,11 @@ }, "actions-languageserver": { "name": "@github/actions-languageserver", - "version": "0.1.113", + "version": "0.1.126", "license": "MIT", "dependencies": { - "@github/actions-languageservice": "^0.1.113", - "@github/actions-workflow-parser": "^0.1.113", + "@github/actions-languageservice": "^0.1.126", + "@github/actions-workflow-parser": "^0.1.126", "@octokit/rest": "^19.0.7", "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.7", @@ -59,11 +59,11 @@ }, "actions-languageservice": { "name": "@github/actions-languageservice", - "version": "0.1.113", + "version": "0.1.126", "license": "MIT", "dependencies": { - "@github/actions-expressions": "^0.1.113", - "@github/actions-workflow-parser": "^0.1.113", + "@github/actions-expressions": "^0.1.126", + "@github/actions-workflow-parser": "^0.1.126", "vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-types": "^3.17.2", "yaml": "^2.1.1" @@ -82,10 +82,10 @@ }, "actions-workflow-parser": { "name": "@github/actions-workflow-parser", - "version": "0.1.113", + "version": "0.1.126", "license": "MIT", "dependencies": { - "@github/actions-expressions": "^0.1.113", + "@github/actions-expressions": "^0.1.126", "cronstrue": "^2.21.0", "yaml": "^2.0.0-8" }, @@ -105,10 +105,10 @@ } }, "browser-playground": { - "version": "0.1.113", + "version": "0.1.126", "license": "MIT", "dependencies": { - "@github/actions-languageserver": "^0.1.113", + "@github/actions-languageserver": "^0.1.126", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2", "monaco-languageclient": "^4.0.3", @@ -14570,8 +14570,8 @@ "@github/actions-languageserver": { "version": "file:actions-languageserver", "requires": { - "@github/actions-languageservice": "^0.1.113", - "@github/actions-workflow-parser": "^0.1.113", + "@github/actions-languageservice": "^0.1.126", + "@github/actions-workflow-parser": "^0.1.126", "@octokit/rest": "^19.0.7", "@types/jest": "^29.0.3", "fetch-mock": "^9.11.0", @@ -14588,8 +14588,8 @@ "@github/actions-languageservice": { "version": "file:actions-languageservice", "requires": { - "@github/actions-expressions": "^0.1.113", - "@github/actions-workflow-parser": "^0.1.113", + "@github/actions-expressions": "^0.1.126", + "@github/actions-workflow-parser": "^0.1.126", "@types/jest": "^29.0.3", "jest": "^29.0.3", "prettier": "^2.8.3", @@ -14604,7 +14604,7 @@ "@github/actions-workflow-parser": { "version": "file:actions-workflow-parser", "requires": { - "@github/actions-expressions": "^0.1.113", + "@github/actions-expressions": "^0.1.126", "@types/jest": "^29.0.3", "@typescript-eslint/eslint-plugin": "^5.40.0", "@typescript-eslint/parser": "^5.40.0", @@ -17562,7 +17562,7 @@ "browser-playground": { "version": "file:browser-playground", "requires": { - "@github/actions-languageserver": "^0.1.113", + "@github/actions-languageserver": "^0.1.126", "css-loader": "^6.7.2", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2",