Compare commits

..
Author SHA1 Message Date
Christopher Schleiden db3896d0aa WIP 2023-04-05 15:17:12 -07:00
20 changed files with 161 additions and 271 deletions
+1 -1
View File
@@ -1 +1 @@
* @actions/actions-workflow-development-reviewers
* @actions/actions-experience
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.2",
"version": "0.3.1",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+1 -1
View File
@@ -32,7 +32,7 @@ export class Evaluator implements ExprVisitor<data.ExpressionData> {
return this.eval(this.n);
}
protected eval(n: Expr): data.ExpressionData {
private eval(n: Expr): data.ExpressionData {
return n.accept(this);
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.2",
"version": "0.3.1",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -43,8 +43,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/languageservice": "^0.3.2",
"@actions/workflow-parser": "^0.3.2",
"@actions/languageservice": "^0.3.1",
"@actions/workflow-parser": "^0.3.1",
"@octokit/rest": "^19.0.7",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
+12 -1
View File
@@ -1,4 +1,4 @@
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
import {documentLinks, documentSymbols, hover, validate, ValidationConfig} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
@@ -7,6 +7,8 @@ import {
Connection,
DocumentLink,
DocumentLinkParams,
DocumentSymbol,
DocumentSymbolParams,
ExecuteCommandParams,
Hover,
HoverParams,
@@ -72,6 +74,10 @@ export function initConnection(connection: Connection) {
hoverProvider: true,
documentLinkProvider: {
resolveProvider: false
},
documentSymbolProvider: {
label: "GitHub Actions",
workDoneProgress: false
}
}
};
@@ -158,6 +164,11 @@ export function initConnection(connection: Connection) {
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
});
connection.onDocumentSymbol(async ({textDocument}: DocumentSymbolParams): Promise<DocumentSymbol[] | null> => {
const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri));
return documentSymbols(getDocument(documents, textDocument), repoContext?.workspaceUri);
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.2",
"version": "0.3.1",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -44,8 +44,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.2",
"@actions/workflow-parser": "^0.3.2",
"@actions/expressions": "^0.3.1",
"@actions/workflow-parser": "^0.3.1",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.7",
+70
View File
@@ -0,0 +1,70 @@
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {File} from "@actions/workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument";
import {DocumentSymbol, SymbolKind} from "vscode-languageserver-types";
import {mapRange} from "./utils/range";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
export async function documentSymbols(
document: TextDocument,
workspace: string | undefined
): Promise<DocumentSymbol[]> {
const file: File = {
name: document.uri,
content: document.getText()
};
const parsedWorkflow = fetchOrParseWorkflow(file, document.uri);
if (!parsedWorkflow?.value) {
return [];
}
const template = await fetchOrConvertWorkflowTemplate(
parsedWorkflow.context,
parsedWorkflow.value,
document.uri,
undefined,
{
errorPolicy: ErrorPolicy.TryConversion
}
);
if (!template) {
return [];
}
const symbols: DocumentSymbol[] = [];
const onSymbol = DocumentSymbol.create(
"`on` Triggers",
undefined,
SymbolKind.Key,
mapRange(template.events.range),
mapRange(template.events.range)
);
symbols.push(onSymbol);
const jobsSymbol = DocumentSymbol.create(
"Jobs",
undefined,
SymbolKind.Namespace,
mapRange(template.jobs?.[0].id.range),
mapRange(template.jobs?.[0].id.range),
[]
);
symbols.push(jobsSymbol);
for (const job of template.jobs || []) {
const jobSymbol = DocumentSymbol.create(
`Job ${job.name?.toDisplayString() || job.id?.toString()}`,
"detail",
SymbolKind.Class,
mapRange(job.id.range),
mapRange(job.id.range)
);
jobsSymbol.children!.push(jobSymbol);
}
return symbols;
}
@@ -1,61 +0,0 @@
import {Evaluator, ExpressionEvaluationError, data} from "@actions/expressions";
import {Expr, Logical} from "@actions/expressions/ast";
import {ExpressionData} from "@actions/expressions/data/expressiondata";
import {TokenType} from "@actions/expressions/lexer";
import {falsy, truthy} from "@actions/expressions/result";
import {AccessError} from "./error-dictionary";
export type ValidationError = {
message: string;
severity: "error" | "warning";
};
export class ValidationEvaluator extends Evaluator {
public readonly errors: ValidationError[] = [];
public validate() {
super.evaluate();
}
protected override eval(n: Expr): ExpressionData {
try {
return super.eval(n);
} catch (e) {
// Record error
if (e instanceof AccessError) {
this.errors.push({
message: `Context access might be invalid: ${e.keyName}`,
severity: "warning"
});
} else if (e instanceof ExpressionEvaluationError) {
this.errors.push({
message: `Expression might be invalid: ${e.message}`,
severity: "error"
});
}
}
// Return null but continue with the validation
return new data.Null();
}
override visitLogical(logical: Logical): ExpressionData {
let result: data.ExpressionData | undefined;
for (const arg of logical.args) {
const r = this.eval(arg);
// Simulate short-circuit behavior but continue to evalute all arguments for validation purposes
if (
!result &&
((logical.operator.type === TokenType.AND && falsy(r)) || (logical.operator.type === TokenType.OR && truthy(r)))
) {
result = r;
}
}
// result is always assigned before we return here
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return result!;
}
}
+3 -2
View File
@@ -1,7 +1,8 @@
export {complete} from "./complete";
export {ContextProviderConfig} from "./context-providers/config";
export {documentLinks} from "./document-links";
export {documentSymbols} from "./document-symbols";
export {hover} from "./hover";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log";
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate";
export {LogLevel, Logger, registerLogger, setLogLevel} from "./log";
export {ActionsMetadataProvider, ValidationConfig, validate} from "./validate";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
@@ -46,52 +46,6 @@ jobs:
]);
});
it("access invalid context field in short-circuited expression", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on: push
run-name: name-\${{ github.does-not-exist || github.does-not-exist2 }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo`
)
);
expect(result).toEqual([
{
message: "Context access might be invalid: does-not-exist",
range: {
end: {
character: 69,
line: 1
},
start: {
character: 15,
line: 1
}
},
severity: DiagnosticSeverity.Warning
},
{
message: "Context access might be invalid: does-not-exist2",
range: {
end: {
character: 69,
line: 1
},
start: {
character: 15,
line: 1
}
},
severity: DiagnosticSeverity.Warning
}
]);
});
it("partial skip access invalid context on incomplete", async () => {
const contextProviderConfig: ContextProviderConfig = {
getContext: (context: string) => {
+26 -16
View File
@@ -1,6 +1,6 @@
import {Lexer, Parser} from "@actions/expressions";
import {Evaluator, ExpressionEvaluationError, Lexer, Parser} from "@actions/expressions";
import {Expr} from "@actions/expressions/ast";
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
import {isBasicExpression, isString, ParseWorkflowResult, WorkflowTemplate} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
@@ -13,10 +13,9 @@ import {TextDocument} from "vscode-languageserver-textdocument";
import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types";
import {ActionMetadata, ActionReference} from "./action";
import {ContextProviderConfig} from "./context-providers/config";
import {Mode, getContext} from "./context-providers/default";
import {WorkflowContext, getWorkflowContext} from "./context/workflow-context";
import {wrapDictionary} from "./expression-validation/error-dictionary";
import {ValidationEvaluator} from "./expression-validation/evaluator";
import {getContext, Mode} from "./context-providers/default";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
import {AccessError, wrapDictionary} from "./expression-validation/error-dictionary";
import {validatorFunctions} from "./expression-validation/functions";
import {error} from "./log";
import {findToken} from "./utils/find-token";
@@ -203,17 +202,28 @@ async function validateExpression(
continue;
}
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
try {
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
const e = new ValidationEvaluator(expr, wrapDictionary(context), validatorFunctions);
e.validate();
const e = new Evaluator(expr, wrapDictionary(context), validatorFunctions);
e.evaluate();
diagnostics.push(
...e.errors.map(e => ({
message: e.message,
range: mapRange(expression.range),
severity: e.severity === "error" ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning
}))
);
// Any invalid context access would've thrown an error via the `ErrorDictionary`, for now we don't have to check the actual
// result of the evaluation.
} catch (e) {
if (e instanceof AccessError) {
diagnostics.push({
message: `Context access might be invalid: ${e.keyName}`,
severity: DiagnosticSeverity.Warning,
range: mapRange(expression.range)
});
} else if (e instanceof ExpressionEvaluationError) {
diagnostics.push({
message: `Expression might be invalid: ${e.message}`,
severity: DiagnosticSeverity.Error,
range: mapRange(expression.range)
});
}
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"useWorkspaces": true,
"version": "0.3.2"
"version": "0.3.1"
}
+12 -13
View File
@@ -135,7 +135,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.2",
"version": "0.3.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -395,11 +395,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.2",
"version": "0.3.1",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.2",
"@actions/workflow-parser": "^0.3.2",
"@actions/languageservice": "^0.3.1",
"@actions/workflow-parser": "^0.3.1",
"@octokit/rest": "^19.0.7",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -678,11 +678,11 @@
},
"languageservice": {
"name": "@actions/languageservice",
"version": "0.3.2",
"version": "0.3.1",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.2",
"@actions/workflow-parser": "^0.3.2",
"@actions/expressions": "^0.3.1",
"@actions/workflow-parser": "^0.3.1",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.7",
@@ -6652,10 +6652,9 @@
"license": "MIT"
},
"node_modules/http-cache-semantics": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
"dev": true
"version": "4.1.0",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/http-proxy-agent": {
"version": "5.0.0",
@@ -11720,10 +11719,10 @@
},
"workflow-parser": {
"name": "@actions/workflow-parser",
"version": "0.3.2",
"version": "0.3.1",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.2",
"@actions/expressions": "^0.3.1",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.2",
"version": "0.3.1",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -43,7 +43,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.2",
"@actions/expressions": "^0.3.1",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+4 -2
View File
@@ -9,7 +9,7 @@ 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";
import {EventsConfig, WorkflowTemplate} from "./workflow-template";
export enum ErrorPolicy {
ReturnErrorsOnly,
@@ -73,7 +73,9 @@ export async function convertWorkflowTemplate(
switch (key.value) {
case "on":
result.events = handleTemplateTokenErrors(root, context, {}, () => convertOn(context, item.value));
result.events = handleTemplateTokenErrors(root, context, {range: item.key.range} as EventsConfig, () =>
convertOn(context, item.value)
);
break;
case "jobs":
+14 -14
View File
@@ -23,12 +23,15 @@ export function convertOn(context: TemplateContext, token: TemplateToken): Event
const event = token.assertString("on");
return {
range: token.range,
[event.value]: {}
} as EventsConfig;
}
if (isSequence(token)) {
const result = {} as EventsConfig;
const result = {
range: token.range
} as EventsConfig;
for (const item of token) {
const event = item.assertString("on");
@@ -39,7 +42,9 @@ export function convertOn(context: TemplateContext, token: TemplateToken): Event
}
if (isMapping(token)) {
const result = {} as EventsConfig;
const result = {
range: token.range
} as EventsConfig;
for (const item of token) {
const eventKey = item.key.assertString("event name");
@@ -59,24 +64,17 @@ export function convertOn(context: TemplateContext, token: TemplateToken): Event
// All other events are defined as mappings. During schema validation we already ensure that events
// receive only known keys, so here we can focus on the values and whether they are valid.
const eventToken = item.value.assertMapping(`event ${eventName}`);
if (eventName === "workflow_call") {
result.workflow_call = convertEventWorkflowCall(context, eventToken);
continue;
}
if (eventName === "workflow_dispatch") {
result.workflow_dispatch = convertEventWorkflowDispatchInputs(context, eventToken);
continue;
}
result[eventName] = {
...convertPatternFilter("branches", eventToken),
...convertPatternFilter("tags", eventToken),
...convertPatternFilter("paths", eventToken),
...convertFilter("types", eventToken),
...convertFilter("workflows", eventToken)
...convertFilter("workflows", eventToken),
// workflow_call and workflow_dispatch share input parsing
...convertEventWorkflowDispatchInputs(context, eventToken),
...convertEventWorkflowCall(context, eventToken)
};
}
@@ -84,7 +82,9 @@ export function convertOn(context: TemplateContext, token: TemplateToken): Event
}
context.error(token, "Invalid format for 'on'");
return {};
return {
range: token.range
} as EventsConfig;
}
function convertPatternFilter<T extends BranchFilterConfig & TagFilterConfig & PathFilterConfig>(
@@ -1,9 +1,7 @@
import {TemplateContext} from "../../templates/template-context";
import {MappingToken, TemplateToken} from "../../templates/tokens";
import {isMapping} from "../../templates/tokens/type-guards";
import {SecretConfig, WorkflowCallConfig, InputConfig, InputType} from "../workflow-template";
import {convertStringList} from "./string-list";
import {ScalarToken} from "../../templates/tokens/scalar-token";
import {SecretConfig, WorkflowCallConfig} from "../workflow-template";
export function convertEventWorkflowCall(context: TemplateContext, token: MappingToken): WorkflowCallConfig {
const result: WorkflowCallConfig = {};
@@ -13,7 +11,7 @@ export function convertEventWorkflowCall(context: TemplateContext, token: Mappin
switch (key.value) {
case "inputs":
result.inputs = convertWorkflowInputs(context, item.value.assertMapping("workflow dispatch inputs"));
// Ignore, these are handled by convertEventWorkflowDispatchInputs
break;
case "secrets":
@@ -29,94 +27,6 @@ export function convertEventWorkflowCall(context: TemplateContext, token: Mappin
return result;
}
export function convertWorkflowInputs(
context: TemplateContext,
token: MappingToken
): {
[inputName: string]: InputConfig;
} {
const result: {[inputName: string]: InputConfig} = {};
for (const item of token) {
const inputName = item.key.assertString("input name");
const inputMapping = item.value.assertMapping("input configuration");
result[inputName.value] = convertWorkflowInput(context, inputMapping);
}
return result;
}
export function convertWorkflowInput(context: TemplateContext, token: MappingToken): InputConfig {
const result: InputConfig = {
type: InputType.string // Default to string
};
let defaultValue: undefined | ScalarToken;
for (const item of token) {
const key = item.key.assertString("workflow dispatch input key");
switch (key.value) {
case "description":
result.description = item.value.assertString("input description").value;
break;
case "required":
result.required = item.value.assertBoolean("input required").value;
break;
case "default":
defaultValue = item.value.assertScalar("input default");
break;
case "type":
result.type = InputType[item.value.assertString("input type").value as keyof typeof InputType];
break;
case "options":
result.options = convertStringList("input options", item.value.assertSequence("input options"));
break;
default:
context.error(item.key, `Invalid key '${key.value}'`);
}
}
// Validate default value
if (defaultValue !== undefined && !defaultValue.isExpression) {
try {
switch (result.type) {
case InputType.boolean:
result.default = defaultValue.assertBoolean("input default").value;
break;
case InputType.string:
case InputType.choice:
case InputType.environment:
result.default = defaultValue.assertString("input default").value;
break;
}
} catch (e) {
context.error(defaultValue, e);
}
}
// Validate `options` for `choice` type
if (result.type === InputType.choice) {
if (result.options === undefined || result.options.length === 0) {
context.error(token, "Missing 'options' for choice input");
}
} else {
if (result.options !== undefined) {
context.error(token, "Input type is not 'choice', but 'options' is defined");
}
}
return result;
}
function convertWorkflowCallSecrets(
context: TemplateContext,
token: MappingToken
@@ -6,6 +6,7 @@ import {
StringToken,
TemplateToken
} from "../templates/tokens";
import {TokenRange} from "../templates/tokens/token-range";
export type WorkflowTemplate = {
events: EventsConfig;
@@ -99,6 +100,8 @@ export type ActionStep = BaseStep & {
};
export type EventsConfig = {
range: TokenRange;
schedule?: ScheduleConfig[];
workflow_dispatch?: WorkflowDispatchConfig;
workflow_call?: WorkflowCallConfig;
@@ -158,7 +161,7 @@ export type WorkflowDispatchConfig = {
};
export type WorkflowCallConfig = {
inputs?: {[inputName: string]: InputConfig & {default?: string | boolean | number | ScalarToken}};
inputs?: {[inputName: string]: InputConfig};
secrets?: {[secretName: string]: SecretConfig};
// TODO - these are supported in C# and Go but not in TS yet
// outputs: { [outputName: string]: OutputConfig }
+1 -2
View File
@@ -576,8 +576,7 @@
"merge-group-mapping": {
"mapping": {
"properties": {
"types": "merge-group-activity",
"branches": "event-branches"
"types": "merge-group-activity"
}
}
},
+1 -9
View File
@@ -72,11 +72,7 @@ on:
- edited
- deleted
merge_group:
branches:
- master
- main
types:
- checks_requested
types: checks_requested
milestone:
types:
- created
@@ -317,10 +313,6 @@ jobs:
]
},
"merge_group": {
"branches": [
"master",
"main"
],
"types": [
"checks_requested"
]