Validate action inputs
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user