Validate action inputs

This commit is contained in:
Josh Gross
2022-12-13 14:58:14 -05:00
parent 3ac171716e
commit caff5ce7b6
8 changed files with 441 additions and 57 deletions
+101
View File
@@ -0,0 +1,101 @@
import {actionIdentifier, parseActionReference as parse} from "./action";
describe("parseActionReference", () => {
it("basic action", () => {
expect(parse("actions/checkout@v2")).toEqual({
owner: "actions",
name: "checkout",
ref: "v2"
});
});
it("action with reference to branch", () => {
expect(parse("actions/checkout@main")).toEqual({
owner: "actions",
name: "checkout",
ref: "main"
});
});
it("action with reference to branch with slashes", () => {
expect(parse("actions/checkout@features/a")).toEqual({
owner: "actions",
name: "checkout",
ref: "features/a"
});
});
it("action with reference to commit sha", () => {
expect(parse("actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b")).toEqual({
owner: "actions",
name: "checkout",
ref: "755da8c3cf115ac066823e79a1e1788f8940201b"
});
});
it("valid action with path", () => {
expect(parse("actions/checkout/path/to/action@v2")).toEqual({
owner: "actions",
name: "checkout",
ref: "v2",
path: "path/to/action"
});
});
it("valid action with path and trailing slash", () => {
expect(parse("actions/checkout/path/to/action/@v2")).toEqual({
owner: "actions",
name: "checkout",
ref: "v2",
path: "path/to/action"
});
});
it("local action", () => {
expect(parse("./")).toBeUndefined();
});
it("local action with path", () => {
expect(parse("./directory/")).toBeUndefined();
});
it("Docker Hub action", () => {
expect(parse("docker://alpine:3.8")).toBeUndefined();
});
it("GitHub Packages Container action", () => {
expect(parse("docker://ghcr.io/OWNER/IMAGE_NAME")).toBeUndefined();
});
it("action with backslashes", () => {
expect(parse("actions\\checkout\\path\\to\\action@v2")).toEqual({
owner: "actions",
name: "checkout",
ref: "v2",
path: "path/to/action"
});
});
});
describe("actionIdentifier", () => {
it("basic action", () => {
expect(
actionIdentifier({
owner: "actions",
name: "checkout",
ref: "v2"
})
).toEqual("actions/checkout/v2");
});
it("action with path", () => {
expect(
actionIdentifier({
owner: "actions",
name: "checkout",
ref: "v2",
path: "path/to/action"
})
).toEqual("actions/checkout/v2/path/to/action");
});
});
+51
View File
@@ -0,0 +1,51 @@
// https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputs
export type ActionInput = {
description: string;
required?: boolean;
default?: string;
deprecationMessage?: string;
};
export type ActionInputs = Record<string, ActionInput>;
export type ActionReference = {
owner: string;
name: string;
ref: string;
path?: string;
};
// https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsuses
export function parseActionReference(uses: string): ActionReference | undefined {
if (!uses || uses.startsWith("docker://") || uses.startsWith("./") || uses.startsWith(".\\")) {
return undefined;
}
const [action, ref] = uses.split("@");
const [owner, name, ...pathSegments] = action.split(/[\\/]/).filter(s => s.length > 0);
if (!owner || !name) {
return undefined;
}
if (pathSegments.length === 0) {
return {
owner,
name,
ref
};
}
return {
owner,
name,
ref,
path: pathSegments.join("/")
};
}
export function actionIdentifier(ref: ActionReference): string {
if (ref.path) {
return `${ref.owner}/${ref.name}/${ref.ref}/${ref.path}`;
}
return `${ref.owner}/${ref.name}/${ref.ref}`;
}
@@ -0,0 +1,82 @@
import {isMapping} from "@github/actions-workflow-parser";
import {isActionStep} from "@github/actions-workflow-parser/model/type-guards";
import {Step} from "@github/actions-workflow-parser/model/workflow-template";
import {ScalarToken} from "@github/actions-workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {parseActionReference} from "./action";
import {mapRange} from "./utils/range";
import {ValidationConfig} from "./validate";
export async function validateAction(
diagnostics: Diagnostic[],
stepToken: TemplateToken,
step: Step | undefined,
config: ValidationConfig | undefined
): Promise<void> {
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.getActionInputs) {
return;
}
const action = parseActionReference(step.uses.value);
if (!action) {
return;
}
const actionInputs = await config.getActionInputs(action);
if (actionInputs === undefined) {
return;
}
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
break;
}
}
const stepInputs = new Map<string, ScalarToken>();
if (withToken && isMapping(withToken)) {
for (const {key} of withToken) {
stepInputs.set(key.toString(), key);
}
}
for (const [input, inputToken] of stepInputs) {
if (!actionInputs[input]) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(inputToken.range),
message: `Invalid action input '${input}'`
});
}
const deprecationMessage = actionInputs[input]?.deprecationMessage;
if (deprecationMessage) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
range: mapRange(inputToken.range),
message: deprecationMessage
});
}
}
const missingRequiredInputs = Object.entries(actionInputs).filter(
([inputName, input]) => input.required && !stepInputs.has(inputName)
);
if (missingRequiredInputs.length > 0) {
const message =
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
message: message
});
}
}
@@ -0,0 +1,259 @@
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {ActionInput, ActionReference} from "./action";
import {registerLogger} from "./log";
import {createDocument} from "./test-utils/document";
import {TestLogger} from "./test-utils/logger";
import {validate, ValidationConfig} from "./validate";
import {ValueProviderKind} from "./value-providers/config";
registerLogger(new TestLogger());
const validationConfig: ValidationConfig = {
getActionInputs: async (ref: ActionReference) => {
let inputs: Record<string, ActionInput> = {};
switch (ref.owner + "/" + ref.name + "@" + ref.ref) {
case "actions/checkout@v3":
inputs = {
repository: {
description: "Repository name with owner",
default: "${{ github.repository }}"
}
};
break;
case "actions/setup-node@v1":
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/cache@v1":
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;
}
return inputs;
}
};
describe("validate action steps", () => {
it("valid action reference", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([]);
});
it("invalid input", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
notanoption: true
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Invalid action input 'notanoption'",
range: {
end: {
character: 19,
line: 8
},
start: {
character: 8,
line: 8
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("deprecated input", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v1
with:
version: 10
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "The version property will not be supported after October 1, 2019. Use node-version instead",
range: {
end: {
character: 15,
line: 8
},
start: {
character: 8,
line: 8
}
},
severity: DiagnosticSeverity.Warning
}
]);
});
it("missing required input", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
key: \${{ runner.os }}-node-\${{ hashFiles('**/package-lock.json') }}
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Missing required input `path`",
range: {
end: {
character: 10,
line: 7
},
start: {
character: 6,
line: 7
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("multiple missing required inputs", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: \${{ runner.os }}-node-
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Missing required inputs: `path`, `key`",
range: {
end: {
character: 10,
line: 7
},
start: {
character: 6,
line: 7
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("missing required inputs without a `with` key", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
`;
const result = await validate(createDocument("wf.yaml", input), validationConfig);
expect(result).toEqual([
{
message: "Missing required inputs: `path`, `key`",
range: {
end: {
character: 0,
line: 7
},
start: {
character: 6,
line: 6
}
},
severity: DiagnosticSeverity.Error
}
]);
});
it("skips extra validation from custom value provider", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
notanoption: true
`;
const config = validationConfig;
config.valueProviderConfig = {
"step-with": {
kind: ValueProviderKind.AllowedValues,
get: async () => {
return [{label: "repository", description: "Repository name with owner."}];
}
}
};
const result = await validate(createDocument("wf.yaml", input), config);
expect(result).toEqual([
{
message: "Invalid action input 'notanoption'",
range: {
end: {
character: 19,
line: 8
},
start: {
character: 8,
line: 8
}
},
severity: DiagnosticSeverity.Error
}
]);
});
});
+23 -15
View File
@@ -16,6 +16,7 @@ import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/te
import {File} from "@github/actions-workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument";
import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types";
import {ActionInputs, ActionReference} from "./action";
import {ContextProviderConfig} from "./context-providers/config";
import {getContext, Mode} from "./context-providers/default";
@@ -26,9 +27,16 @@ import {nullTrace} from "./nulltrace";
import {getAllowedContext} from "./utils/allowed-context";
import {findToken} from "./utils/find-token";
import {mapRange} from "./utils/range";
import {validateAction} from "./validate-action";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
import {defaultValueProviders} from "./value-providers/default";
export type ValidationConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
getActionInputs?(action: ActionReference): Promise<ActionInputs | undefined>;
};
/**
* Validates a workflow file
*
@@ -37,9 +45,8 @@ import {defaultValueProviders} from "./value-providers/default";
*/
export async function validate(
textDocument: TextDocument,
config?: ValidationConfig
// TODO: Support multiple files, context for API calls
valueProviderConfig?: ValueProviderConfig,
contextProviderConfig?: ContextProviderConfig
): Promise<Diagnostic[]> {
const file: File = {
name: textDocument.uri,
@@ -55,14 +62,7 @@ export async function validate(
const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion);
// Validate expressions and value providers
await additionalValidations(
diagnostics,
textDocument.uri,
template,
result.value,
valueProviderConfig,
contextProviderConfig
);
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config);
}
// For now map parser errors directly to diagnostics
@@ -86,8 +86,7 @@ async function additionalValidations(
documentUri: URI,
template: WorkflowTemplate,
root: TemplateToken,
valueProviderConfig: ValueProviderConfig | undefined,
contextProviderConfig: ContextProviderConfig | undefined
config?: ValidationConfig
) {
for (const [parent, token, key] of TemplateToken.traverse(root)) {
// If the token is a value in a pair, use the key definition for validation
@@ -103,18 +102,27 @@ async function additionalValidations(
diagnostics,
token,
allowedContext,
contextProviderConfig,
config?.contextProviderConfig,
getProviderContext(documentUri, template, root, token)
);
}
if (token.definition?.key === "regular-step") {
const context = getProviderContext(documentUri, template, root, token);
await validateAction(diagnostics, token, context.step, config);
}
// Allowed values coming from the schema have already been validated. Only check if
// a value provider is defined for a token and if it is, validate the values match.
if (valueProviderConfig && token.range && validationDefinition) {
if (config?.valueProviderConfig && token.range && validationDefinition) {
const defKey = validationDefinition.key;
if (defKey === "step-with") {
// Action inputs should be validated already in validateAction
continue;
}
// Try a custom value provider first
let valueProvider = valueProviderConfig[defKey];
let valueProvider = config.valueProviderConfig[defKey];
if (!valueProvider) {
// fall back to default
valueProvider = defaultValueProviders[defKey];