Merge branch 'main' into elbrenn/expression-complete

This commit is contained in:
Beth Brennan
2023-02-07 17:23:37 -05:00
committed by GitHub
121 changed files with 3265 additions and 1204 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.md
*.js
*.json
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@github/actions-expressions",
"version": "0.1.113",
"version": "0.1.126",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+1 -1
View File
@@ -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();
}
@@ -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);
}
}
+1 -1
View File
@@ -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;
+134 -60
View File
@@ -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);
}
}
);
});
+22 -7
View File
@@ -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
});
}
+6 -6
View File
@@ -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();
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.md
*.js
*.json
+3 -3
View File
@@ -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",
+9 -2
View File
@@ -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<Hover | null> => {
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)
});
});
@@ -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(
@@ -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({
@@ -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<string>
): FileProvider | undefined {
if (!client && !workspace) {
return undefined;
}
return {
getFileContent: async (ref): Promise<File> => {
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<File> {
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")
};
}
@@ -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<WorkflowContext> {
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) {
+3 -3
View File
@@ -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"
@@ -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);
+11 -33
View File
@@ -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 || "<no details>"}'`);
@@ -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;
}
@@ -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.<job_id>.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.<job_id>.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`."
}
}
}
@@ -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) {
@@ -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());
@@ -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<WorkflowContext> {
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
@@ -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;
}
@@ -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<DocumentLin
content: document.getText()
};
const result = parseWorkflow(file.name, [file], nullTrace);
const result = parseWorkflow(file, nullTrace);
if (!result.value) {
return [];
}
const template = convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
const template = await convertWorkflowTemplate(result.context, result.value!, ErrorPolicy.TryConversion);
// Add links to referenced actions
const actionLinks: DocumentLink[] = [];
@@ -27,7 +28,10 @@ export async function documentLinks(document: TextDocument): Promise<DocumentLin
const gitHubBaseUri = "https://www.github.com/";
for (const job of template?.jobs || []) {
for (const step of job?.steps || []) {
if (!job || !isJob(job)) {
continue;
}
for (const step of job.steps || []) {
if ("uses" in step) {
const actionRef = parseActionReference(step.uses.value);
if (!actionRef) {
@@ -0,0 +1,92 @@
import {parseWorkflow} from "@github/actions-workflow-parser/.";
import {File} from "@github/actions-workflow-parser/workflows/file";
import {nullTrace} from "../nulltrace";
import {getPositionFromCursor} from "../test-utils/cursor-position";
import {findToken} from "../utils/find-token";
import {ExpressionPos, mapToExpressionPos} from "./expression-pos";
describe("mapToExpressionPos", () => {
it("simple expression", () => {
expect(
testMapToExpressionPos(`on: push
run-name: \${{ git|hub.event }}`)
).toEqual<ExpressionPos>({
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<ExpressionPos>({
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<ExpressionPos>({
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<ExpressionPos>({
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);
}
@@ -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
};
}
@@ -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
);
}
@@ -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);
}
@@ -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<HoverResult> {
private ignorePosCheck = false;
constructor(
private pos: Pos,
private context: DescriptionDictionary,
private extensionFunctions: FunctionInfo[],
private functions: Map<string, FunctionDefinition>
) {}
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;
}
}
@@ -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<Hover>({
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<Hover>({
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<Hover>({
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<Hover>({
contents: "Name for the test",
range: {
start: {line: 7, character: 18},
end: {line: 7, character: 34}
}
});
});
});
+1 -1
View File
@@ -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, __) => {
+91 -15
View File
@@ -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<string | undefined>;
};
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<Hover | null> {
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;
}
}
@@ -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;
}
@@ -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
);
@@ -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;
}
}
+13 -7
View File
@@ -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<ActionInputs | undefined>;
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<Diagnostic[]> {
export async function validate(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
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);
@@ -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([]);
});
});
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.md
*.js
*.json
+2 -2
View File
@@ -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"
},
+31 -38
View File
@@ -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
);
+17 -29
View File
@@ -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
);
@@ -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);
+76 -3
View File
@@ -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<WorkflowTemplateConverterOptions> = {
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<WorkflowTemplate> {
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<WorkflowTemplateConverterOptions> {
return {
maxReusableWorkflowDepth:
options.maxReusableWorkflowDepth !== undefined
? options.maxReusableWorkflowDepth
: defaultOptions.maxReusableWorkflowDepth,
fetchReusableWorkflowDepth:
options.fetchReusableWorkflowDepth !== undefined
? options.fetchReusableWorkflowDepth
: defaultOptions.fetchReusableWorkflowDepth
};
}
@@ -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;
}
@@ -0,0 +1,54 @@
import {TemplateContext} from "../../../templates/template-context";
import {MappingToken, TemplateToken} from "../../../templates/tokens";
import {ReusableWorkflowJob} from "../../workflow-template";
type TokenMap = Map<string, [key: string, value: TemplateToken]>;
export function convertWorkflowJobInputs(context: TemplateContext, job: ReusableWorkflowJob) {
const inputDefinitions = createTokenMap(
job["input-definitions"]?.assertMapping("workflow job input definitions"),
"inputs"
);
const inputValues = createTokenMap(job["input-values"]?.assertMapping("workflow job input values"), "with");
if (inputDefinitions !== undefined) {
for (const [_, [name, value]] of inputDefinitions) {
const inputSpec = createTokenMap(value.assertMapping(`input ${name}`), `input ${name} key`)!;
const inputTypeToken = inputSpec.get("type")?.[1];
if (!inputTypeToken) {
// This should be validated by the template reader per the schema
continue;
}
const inputSet = inputValues !== undefined && inputValues.has(name.toLowerCase());
const required = inputSpec.get("required")?.[1].assertBoolean(`input ${name} required`).value;
if (required && !inputSet) {
context.error(job.ref, `Input ${name} is required, but not provided while calling.`);
}
}
}
if (inputValues !== undefined) {
for (const [_, [name, value]] of inputValues) {
if (!inputDefinitions?.has(name.toLowerCase())) {
context.error(value, `Invalid input, ${name} is not defined in the referenced workflow.`);
}
}
}
}
function createTokenMap(mapping: MappingToken | undefined, description: string): TokenMap | undefined {
if (!mapping) {
return undefined;
}
const result = new Map<string, [key: string, value: TemplateToken]>();
for (const item of mapping) {
const name = item.key.assertString(`${description} key`);
result.set(name.value.toLowerCase(), [name.value, item.value]);
}
return result;
}
@@ -0,0 +1,102 @@
import {TemplateContext} from "../../../templates/template-context";
import {TemplateToken} from "../../../templates/tokens";
import {isMapping, isString, isSequence} from "../../../templates/tokens/type-guards";
type RunsOn = {
labels: Set<string>;
group: string;
};
export function convertRunsOn(context: TemplateContext, token: TemplateToken): RunsOn {
const labels = convertRunsOnLabels(token);
if (!isMapping(token)) {
return {
labels,
group: ""
};
}
let group = "";
for (const item of token) {
const key = item.key.assertString("job runs-on property name");
switch (key.value) {
case "group": {
if (item.value.isExpression) {
continue;
}
const groupName = item.value.assertString("job runs-on group name").value;
const names = groupName.split("/");
switch (names.length) {
case 1: {
group = groupName;
break;
}
case 2: {
if (!["org", "organization", "ent", "enterprise"].includes(names[0])) {
context.error(
item.value,
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
);
continue;
}
if (!names[1]) {
context.error(item.value, `Invalid runs-on group name '${groupName}'.`);
continue;
}
group = groupName;
break;
}
default: {
context.error(
item.value,
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
);
break;
}
}
break;
}
case "labels": {
const mapLabels = convertRunsOnLabels(item.value);
for (const label of mapLabels) {
labels.add(label);
}
break;
}
}
}
return {
labels,
group
};
}
function convertRunsOnLabels(token: TemplateToken): Set<string> {
const labels = new Set<string>();
if (token.isExpression) {
return labels;
}
if (isString(token)) {
labels.add(token.value);
return labels;
}
if (isSequence(token)) {
for (const item of token) {
if (item.isExpression) {
continue;
}
const label = item.assertString("job runs-on label sequence item");
labels.add(label.value);
}
}
return labels;
}
@@ -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<string>;
group: string;
};
function convertRunsOn(context: TemplateContext, token: TemplateToken): RunsOn {
const labels = convertRunsOnLabels(token);
if (!isMapping(token)) {
return {
labels,
group: ""
};
}
let group = "";
for (const item of token) {
const key = item.key.assertString("job runs-on property name");
switch (key.value) {
case "group": {
if (item.value.isExpression) {
continue;
}
const groupName = item.value.assertString("job runs-on group name").value;
const names = groupName.split("/");
switch (names.length) {
case 1: {
group = groupName;
break;
}
case 2: {
if (!["org", "organization", "ent", "enterprise"].includes(names[0])) {
context.error(
item.value,
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
);
continue;
}
if (!names[1]) {
context.error(item.value, `Invalid runs-on group name '${groupName}'.`);
continue;
}
group = groupName;
break;
}
default: {
context.error(
item.value,
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
);
break;
}
}
break;
}
case "labels": {
const mapLabels = convertRunsOnLabels(item.value);
for (const label of mapLabels) {
labels.add(label);
}
break;
}
}
}
return {
labels,
group
};
}
function convertRunsOnLabels(token: TemplateToken): Set<string> {
const labels = new Set<string>();
if (token.isExpression) {
return labels;
}
if (isString(token)) {
labels.add(token.value);
return labels;
}
if (isSequence(token)) {
for (const item of token) {
if (item.isExpression) {
continue;
}
const label = item.assertString("job runs-on label sequence item");
labels.add(label.value);
}
}
return labels;
}
@@ -0,0 +1,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.");
}
@@ -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) {
@@ -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";
}
@@ -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;
@@ -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 <ParseExpressionResult>{
expression: new BasicExpressionToken(this._fileId, range, trimmed, definitionInfo, undefined, value),
expression: new BasicExpressionToken(
this._fileId,
range,
trimmed,
definitionInfo,
undefined,
token.source,
expressionRange
),
error: undefined
};
}
@@ -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
);
}
@@ -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
);
@@ -2,6 +2,7 @@
export type Position = {
/** The one-based line value */
line: number;
/** The one-based column value */
column: number;
};
@@ -0,0 +1,6 @@
import {File} from "./file";
import {FileReference} from "./file-reference";
export interface FileProvider {
getFileContent(ref: FileReference): Promise<File>;
}
@@ -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@");
});
});
@@ -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}`;
}
@@ -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();
@@ -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 {
@@ -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
);
+41 -16
View File
@@ -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<string, File> = {};
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
+3 -3
View File
@@ -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 }}
@@ -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."
}
]
}
@@ -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 ''"
}
]
}
@@ -15,7 +15,7 @@ jobs:
steps:
- run: echo hi
build3:
runs-on:
runs-on:
group: ent/
steps:
- run: echo hi
@@ -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'"
}
]
}
@@ -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"
}
]
}
@@ -1,7 +1,7 @@
include-source: false # Drop file/line/col from output
max-depth: 5
skip:
- TypeScript
max-depth: 5
---
on: push
jobs:
@@ -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:
@@ -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:
@@ -1,7 +1,6 @@
include-source: false # Drop file/line/col from output
skip:
- Go
- TypeScript
---
on: push
jobs:
@@ -1,7 +1,6 @@
include-source: false # Drop file/line/col from output
skip:
- Go
- TypeScript
---
on: push
jobs:
@@ -1,7 +1,6 @@
include-source: false # Drop file/line/col from output
skip:
- Go
- TypeScript
---
on: push
jobs:
@@ -1,8 +1,8 @@
include-source: false # Drop file/line/col from output
max-result-size: 2048
skip:
- TypeScript
- Go
- TypeScript
---
on: push
jobs:
@@ -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
@@ -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"
}
]
}
}
]
}
]
}
+94
View File
@@ -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"
}
]
}
]
}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- run: echo hi
build4:
build4:
needs:
- build
- build2
+285
View File
@@ -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"
}
]
}
]
}
+3 -3
View File
@@ -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:
@@ -1,6 +1,6 @@
include-source: false # Drop file/line/col from output
skip:
- Typescript
- TypeScript
---
on:
workflow_dispatch:
@@ -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": [
{
@@ -1,6 +1,6 @@
include-source: false # Drop file/line/col from output
skip:
- Typescript
- TypeScript
---
on:
workflow_dispatch:
+1 -1
View File
@@ -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
@@ -1,6 +1,5 @@
include-source: true # Preserve file/line/col in serialized output
skip:
- Go
- TypeScript
---
on: push
@@ -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": {
@@ -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
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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"
}
]
}
]
}
]
}
]
}
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",

Some files were not shown because too many files have changed in this diff Show More