Compare commits

..

11 Commits

Author SHA1 Message Date
Christopher Schleiden 6ee228549a Validate context expressions 2023-04-04 16:40:04 -07:00
Christopher Schleiden 45a665690b Remove error-dictionary 2023-04-04 16:31:07 -07:00
Christopher Schleiden 0a9f420c96 Improve expression validation 2023-04-04 16:29:43 -07:00
Christopher Schleiden fb962e6c47 Merge pull request #11 from actions/release/0.3.1
Release version 0.3.1
2023-03-28 12:13:26 -07:00
GitHub Actions e3db56d6ed Release extension version 0.3.1 2023-03-28 19:12:56 +00:00
Beth Brennan 872c873c9d Merge pull request #10 from actions/release/0.3.0
Release version 0.3.0
2023-03-27 17:13:13 -04:00
GitHub Actions 29e676fad1 Release extension version 0.3.0 2023-03-27 20:49:33 +00:00
Beth Brennan e8b3c20aca Merge pull request #9 from actions/elbrenn/actions-provider
Wrap fetchActionsMetadata in provider checking sessionToken
2023-03-27 16:47:05 -04:00
Beth Brennan 50f9c04a91 Wrap fetchActionsMetadata in provider checking sessionToken 2023-03-27 14:00:58 -04:00
Christopher Schleiden 9c4b8a4c1c Merge pull request #8 from actions/joshmgross/update-codeowners
Update CODEOWNERS
2023-03-23 16:37:45 -07:00
Josh Gross 6b1e4ff115 Update CODEOWNERS 2023-03-23 19:07:33 -04:00
17 changed files with 461 additions and 2449 deletions
+1 -1
View File
@@ -1 +1 @@
* @github/c2c-actions-experience-parser-reviewers
* @actions/actions-experience
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.2.0",
"version": "0.3.1",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.2.0",
"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.2.0",
"@actions/workflow-parser": "^0.2.0",
"@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",
+2 -8
View File
@@ -26,7 +26,7 @@ import {getFileProvider} from "./file-provider";
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
import {onCompletion} from "./on-completion";
import {ReadFileRequest, Requests} from "./request";
import {fetchActionMetadata} from "./utils/action-metadata";
import {getActionsMetadataProvider} from "./utils/action-metadata";
import {TTLCache} from "./utils/cache";
import {timeOperation} from "./utils/timer";
import {valueProviders} from "./value-providers";
@@ -108,13 +108,7 @@ export function initConnection(connection: Connection) {
const config: ValidationConfig = {
valueProviderConfig: valueProviders(client, repoContext, cache),
contextProviderConfig: contextProviders(client, repoContext, cache),
fetchActionMetadata: async action => {
if (client) {
return await fetchActionMetadata(client, cache, action);
}
return undefined;
},
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
})
@@ -1,10 +1,24 @@
import {actionIdentifier, ActionMetadata, ActionReference} from "@actions/languageservice/action";
import {ActionsMetadataProvider} from "@actions/languageservice";
import {error} from "@actions/languageservice/log";
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
import {parse} from "yaml";
import {TTLCache} from "./cache";
import {errorMessage, errorStatus} from "./error";
export function getActionsMetadataProvider(
client: Octokit | undefined,
cache: TTLCache
): ActionsMetadataProvider | undefined {
if (!client) {
return undefined;
}
return {
fetchActionMetadata: async action => fetchActionMetadata(client, cache, action)
};
}
export async function fetchActionMetadata(
client: Octokit,
cache: TTLCache,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.2.0",
"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.2.0",
"@actions/workflow-parser": "^0.2.0",
"@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",
@@ -1,44 +0,0 @@
import {data, isDescriptionDictionary} from "@actions/expressions";
import {isDictionary} from "@actions/expressions/data/dictionary";
import {ExpressionData, Pair} from "@actions/expressions/data/expressiondata";
export class AccessError extends Error {
constructor(message: string, public readonly keyName: string) {
super(message);
}
}
export class ErrorDictionary extends data.Dictionary {
constructor(...pairs: Pair[]) {
super(...pairs);
}
public complete = true;
get(key: string): ExpressionData | undefined {
const value = super.get(key);
if (value) {
return value;
}
if (this.complete) {
throw new AccessError(`Invalid context access: ${key}`, key);
}
}
}
export function wrapDictionary(d: data.Dictionary): ErrorDictionary {
const e = new ErrorDictionary();
if (isDescriptionDictionary(d)) {
e.complete = d.complete;
}
for (const {key, value} of d.pairs()) {
if (isDictionary(value)) {
e.add(key, wrapDictionary(value));
} else {
e.add(key, value);
}
}
return e;
}
@@ -0,0 +1,129 @@
import {Lexer, Parser} from "@actions/expressions";
import {Dictionary} from "@actions/expressions/data/dictionary";
import {StringData} from "@actions/expressions/data/string";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {ValidationVisitor} from "./visitor";
const testContext = new Dictionary({
key: "github",
value: new Dictionary(
{
key: "event",
value: new StringData("push")
},
{
key: "repo",
value: new Dictionary({
key: "name",
value: new StringData("test")
})
}
)
});
function useVisitor(expression: string, allowedContext: string[]): any[] {
const {namedContexts, functions} = splitAllowedContext(allowedContext);
const l = new Lexer(expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
const e = new ValidationVisitor(expr, testContext);
e.validate();
return e.errors;
}
describe("validation visitor", () => {
it("invalid context access", () => {
expect(useVisitor("github.foo", ["github"])).toEqual([
{
message: "Context access might be invalid: foo",
range: {
end: {
column: 10,
line: 0
},
start: {
column: 0,
line: 0
}
},
severity: "warning"
}
]);
});
it("invalid context access as index", () => {
expect(useVisitor("github[github.foo]", ["github"])).toEqual([
{
message: "Context access might be invalid: foo",
range: {
end: {
column: 17,
line: 0
},
start: {
column: 7,
line: 0
}
},
severity: "warning"
}
]);
});
it("invalid nested context access", () => {
expect(useVisitor("github.repo.name", ["github"])).toEqual([
{
message: "Context access might be invalid: name",
range: {
end: {
column: 16,
line: 0
},
start: {
column: 0,
line: 0
}
},
severity: "warning"
}
]);
});
it("invalid context accesses", () => {
expect(useVisitor("github.foo || github.foo.bar", ["github"])).toEqual([
{
message: "Context access might be invalid: foo",
range: {
end: {
column: 10,
line: 0
},
start: {
column: 0,
line: 0
}
},
severity: "warning"
},
{
message: "Context access might be invalid: bar",
range: {
end: {
column: 28,
line: 0
},
start: {
column: 14,
line: 0
}
},
severity: "warning"
}
]);
});
});
@@ -0,0 +1,148 @@
import {DescriptionDictionary} from "@actions/expressions";
import {
Binary,
ContextAccess,
Expr,
ExprVisitor,
FunctionCall,
Grouping,
IndexAccess,
Literal,
Logical,
Unary
} from "@actions/expressions/ast";
import {Dictionary} from "@actions/expressions/data/dictionary";
import {ExpressionData} from "@actions/expressions/data/expressiondata";
import {Range} from "@actions/expressions/lexer";
export type ValidationError = {
range: Range;
message: string;
severity: "error" | "warning";
};
export class ValidationVisitor implements ExprVisitor<void> {
public readonly errors: ValidationError[] = [];
constructor(private expr: Expr, private context: Dictionary) {}
validate(): void {
this._validate(this.expr);
}
private _validate(expr: Expr) {
expr.accept(this);
}
visitLiteral() {
return undefined;
}
visitUnary(unary: Unary) {
this._validate(unary.expr);
}
visitBinary(binary: Binary) {
this._validate(binary.left);
this._validate(binary.right);
}
visitLogical(logical: Logical) {
for (const arg of logical.args) {
this._validate(arg);
}
}
visitGrouping(grouping: Grouping) {
this._validate(grouping.group);
}
visitContextAccess(contextAccess: ContextAccess) {
const contextName = contextAccess.name.lexeme;
if (this.context.get(contextName) === undefined) {
this.errors.push({
message: `Context access might be invalid: ${contextName}`,
range: contextAccess.name.range,
severity: "error"
});
}
}
visitIndexAccess(indexAccess: IndexAccess) {
let contextAccess: ContextAccess | undefined;
const s: ExpressionData[] = [];
let i: Expr = indexAccess;
while (i) {
if (i instanceof IndexAccess) {
if (!(i.index instanceof Literal)) {
// Not a literal, validate independently
this._validate(i.index);
return;
}
s.push(i.index.literal);
i = i.expr;
}
if (i instanceof ContextAccess) {
contextAccess = i;
break;
}
}
if (!contextAccess) {
// Context not found, should not happen, ignore in this case
return;
}
const contextName = contextAccess.name.lexeme;
let contextValue = this.context.get(contextName);
if (contextValue === undefined || !(contextValue instanceof Dictionary)) {
const contextName = contextAccess.name.lexeme;
if (this.context.get(contextName) === undefined) {
this.errors.push({
message: `Context access might be invalid: ${contextName}`,
range: contextAccess.name.range,
severity: "warning"
});
}
return;
}
while (s.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const idx = s.pop()!;
const key = idx.coerceString();
const v: ExpressionData | undefined = contextValue.get(key);
if (v === undefined) {
if (contextValue instanceof DescriptionDictionary && !contextValue.complete) {
// If the context dictionary is not complete, we cannot validate the expression
return;
}
this.errors.push({
range: {
start: contextAccess.name.range.start,
end: (indexAccess.index as Literal).token.range.end
},
message: `Context access might be invalid: ${key}`,
severity: "warning"
});
return;
}
if (!(v instanceof Dictionary)) {
return;
}
contextValue = v;
}
}
visitFunctionCall(functionCall: FunctionCall) {
for (const arg of functionCall.args) {
this._validate(arg);
}
}
}
+1 -1
View File
@@ -3,5 +3,5 @@ export {ContextProviderConfig} from "./context-providers/config";
export {documentLinks} from "./document-links";
export {hover} from "./hover";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log";
export {validate, ValidationConfig} from "./validate";
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
+2 -2
View File
@@ -14,7 +14,7 @@ export async function validateAction(
step: Step | undefined,
config: ValidationConfig | undefined
): Promise<void> {
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.fetchActionMetadata) {
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.actionsMetadataProvider) {
return;
}
@@ -23,7 +23,7 @@ export async function validateAction(
return;
}
const actionMetadata = await config.fetchActionMetadata(action);
const actionMetadata = await config.actionsMetadataProvider.fetchActionMetadata(action);
if (actionMetadata === undefined) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
+81 -64
View File
@@ -14,75 +14,77 @@ beforeEach(() => {
});
const validationConfig: ValidationConfig = {
fetchActionMetadata: (ref: ActionReference) => {
let metadata: ActionMetadata | undefined = undefined;
switch (ref.owner + "/" + ref.name + "@" + ref.ref) {
case "actions/checkout@v3":
metadata = {
name: "Checkout",
description: "Checkout a Git repository at a particular version",
inputs: {
repository: {
description: "Repository name with owner",
default: "${{ github.repository }}"
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference) => {
let metadata: ActionMetadata | undefined = undefined;
switch (ref.owner + "/" + ref.name + "@" + ref.ref) {
case "actions/checkout@v3":
metadata = {
name: "Checkout",
description: "Checkout a Git repository at a particular version",
inputs: {
repository: {
description: "Repository name with owner",
default: "${{ github.repository }}"
}
}
}
};
break;
case "actions/setup-node@v1":
metadata = {
name: "Setup Node.js environment",
description:
"Setup a Node.js environment by adding problem matchers and optionally downloading and adding it to the PATH.",
inputs: {
version: {
description: "Deprecated. Use node-version instead. Will not be supported after October 1, 2019",
deprecationMessage:
"The version property will not be supported after October 1, 2019. Use node-version instead"
};
break;
case "actions/setup-node@v1":
metadata = {
name: "Setup Node.js environment",
description:
"Setup a Node.js environment by adding problem matchers and optionally downloading and adding it to the PATH.",
inputs: {
version: {
description: "Deprecated. Use node-version instead. Will not be supported after October 1, 2019",
deprecationMessage:
"The version property will not be supported after October 1, 2019. Use node-version instead"
}
}
}
};
break;
case "actions/deploy-pages@main":
metadata = {
name: "Deploy GitHub Pages site",
description: "A GitHub Action to deploy an artifact as a GitHub Pages site",
inputs: {
token: {
required: true,
description: "token to use",
default: "${{ github.token }}"
};
break;
case "actions/deploy-pages@main":
metadata = {
name: "Deploy GitHub Pages site",
description: "A GitHub Action to deploy an artifact as a GitHub Pages site",
inputs: {
token: {
required: true,
description: "token to use",
default: "${{ github.token }}"
}
}
}
};
break;
case "actions/cache@v1":
metadata = {
name: "Cache",
description: "Cache artifacts like dependencies and build outputs to improve workflow execution time",
inputs: {
path: {
description: "A directory to store and save the cache",
required: true
},
key: {
description: "An explicit key for restoring and saving the cache",
required: true
},
"restore-keys": {
description: "An ordered list of keys to use for restoring the cache if no cache hit occurred for key",
required: false
};
break;
case "actions/cache@v1":
metadata = {
name: "Cache",
description: "Cache artifacts like dependencies and build outputs to improve workflow execution time",
inputs: {
path: {
description: "A directory to store and save the cache",
required: true
},
key: {
description: "An explicit key for restoring and saving the cache",
required: true
},
"restore-keys": {
description: "An ordered list of keys to use for restoring the cache if no cache hit occurred for key",
required: false
}
}
}
};
break;
case "actions/action-no-input@v1":
metadata = {
name: "Action with no inputs",
description: "An action with no inputs"
};
};
break;
case "actions/action-no-input@v1":
metadata = {
name: "Action with no inputs",
description: "An action with no inputs"
};
}
return Promise.resolve(metadata);
}
return Promise.resolve(metadata);
}
};
@@ -101,6 +103,21 @@ jobs:
expect(result).toEqual([]);
});
it("no actionsMetadataProvider", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/does-not-exist@v3
`;
const config: ValidationConfig = {};
const result = await validate(createDocument("wf.yaml", input), config);
expect(result).toEqual([]);
});
it("action does not exist", async () => {
const input = `
on: push
@@ -1,4 +1,4 @@
import {DescriptionDictionary} from "@actions/expressions/.";
import {DescriptionDictionary} from "@actions/expressions";
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config";
import {registerLogger} from "./log";
+20 -28
View File
@@ -1,6 +1,6 @@
import {Evaluator, ExpressionEvaluationError, Lexer, Parser} from "@actions/expressions";
import {Lexer, Parser} from "@actions/expressions";
import {Expr} from "@actions/expressions/ast";
import {isBasicExpression, isString, ParseWorkflowResult, WorkflowTemplate} from "@actions/workflow-parser";
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} 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 {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 {Mode, getContext} from "./context-providers/default";
import {WorkflowContext, getWorkflowContext} from "./context/workflow-context";
import {ValidationVisitor} from "./expression-validation/visitor";
import {error} from "./log";
import {findToken} from "./utils/find-token";
import {mapRange} from "./utils/range";
@@ -28,10 +27,14 @@ import {defaultValueProviders} from "./value-providers/default";
export type ValidationConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
fetchActionMetadata?(action: ActionReference): Promise<ActionMetadata | undefined>;
actionsMetadataProvider?: ActionsMetadataProvider;
fileProvider?: FileProvider;
};
export type ActionsMetadataProvider = {
fetchActionMetadata(action: ActionReference): Promise<ActionMetadata | undefined>;
};
/**
* Validates a workflow file
*
@@ -198,28 +201,17 @@ async function validateExpression(
continue;
}
try {
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
const e = new Evaluator(expr, wrapDictionary(context), validatorFunctions);
e.evaluate();
const e = new ValidationVisitor(expr, context);
e.validate();
// 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)
});
}
}
diagnostics.push(
...e.errors.map(e => ({
message: e.message,
range: mapRange(expression.range),
severity: e.severity === "error" ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning
}))
);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"useWorkspaces": true,
"version": "0.2.0"
"version": "0.3.1"
}
+52 -2290
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.2.0",
"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.2.0",
"@actions/expressions": "^0.3.1",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},