Add language service support for action.yml files (#275)
- Add validation, completion, hover, and document links for action.yml files - Implement document type detection to route action.yml to action-specific handlers - Add expression context for composite actions (inputs, steps, github, runner, etc.) - Add schema validation for required fields, branding, and composite step requirements - Support JavaScript (node20/node24), Docker, and composite action types - Validate action references in composite action uses steps - Add JSDoc comments to parser and template functions - Refactor hover to use hoverToken consistently - Fix lint errors and add return type annotations
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {complete} from "./complete";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("complete action files", () => {
|
||||
function createActionDocument(
|
||||
content: string,
|
||||
uri = "file:///test/action.yml"
|
||||
): [TextDocument, {line: number; character: number}] {
|
||||
// Parse cursor position and remove the | character
|
||||
const cursorIndex = content.indexOf("|");
|
||||
if (cursorIndex === -1) {
|
||||
throw new Error("No cursor (|) found in content");
|
||||
}
|
||||
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
|
||||
const doc = TextDocument.create(uri, "yaml", 1, newContent);
|
||||
const position = doc.positionAt(cursorIndex);
|
||||
return [doc, position];
|
||||
}
|
||||
|
||||
describe("expression completion in composite actions", () => {
|
||||
it("completes inputs context", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
inputs:
|
||||
name:
|
||||
description: The name
|
||||
greeting:
|
||||
description: The greeting
|
||||
default: Hello
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "\${{ inputs.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("name");
|
||||
expect(labels).toContain("greeting");
|
||||
});
|
||||
|
||||
it("completes steps context with prior step IDs", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: step1
|
||||
run: echo "hello"
|
||||
shell: bash
|
||||
- id: step2
|
||||
run: echo "\${{ steps.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("step1");
|
||||
expect(labels).not.toContain("step2"); // Current step should not be included
|
||||
});
|
||||
|
||||
it("completes step properties", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: greet
|
||||
run: echo "hello"
|
||||
shell: bash
|
||||
- run: echo "\${{ steps.greet.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("outputs");
|
||||
expect(labels).toContain("outcome");
|
||||
expect(labels).toContain("conclusion");
|
||||
});
|
||||
|
||||
it("does not include steps from after cursor position", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: first
|
||||
run: echo "first"
|
||||
shell: bash
|
||||
- run: echo "\${{ steps.| }}"
|
||||
shell: bash
|
||||
- id: last
|
||||
run: echo "last"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("first");
|
||||
expect(labels).not.toContain("last");
|
||||
});
|
||||
|
||||
it("completes github context in actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "\${{ github.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("actor");
|
||||
expect(labels).toContain("repository");
|
||||
expect(labels).toContain("ref");
|
||||
});
|
||||
|
||||
it("completes runner context in actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "\${{ runner.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("os");
|
||||
expect(labels).toContain("arch");
|
||||
expect(labels).toContain("temp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("top-level completions", () => {
|
||||
it("completes top-level keys", async () => {
|
||||
const [doc, position] = createActionDocument(`n|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("name");
|
||||
});
|
||||
|
||||
it("completes at empty line", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("description");
|
||||
expect(labels).toContain("runs");
|
||||
expect(labels).toContain("inputs");
|
||||
expect(labels).toContain("outputs");
|
||||
expect(labels).toContain("branding");
|
||||
expect(labels).toContain("author");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs completions", () => {
|
||||
it("completes runs.using values", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: |`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("composite");
|
||||
expect(labels).toContain("node20");
|
||||
expect(labels).toContain("docker");
|
||||
});
|
||||
|
||||
it("completes runs keys", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("using");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding completions", () => {
|
||||
it("completes branding keys", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
branding:
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("icon");
|
||||
expect(labels).toContain("color");
|
||||
});
|
||||
|
||||
it("completes branding color values", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
branding:
|
||||
color: |`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("blue");
|
||||
expect(labels).toContain("green");
|
||||
expect(labels).toContain("red");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inputs completions", () => {
|
||||
it("completes input property keys", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
inputs:
|
||||
my-input:
|
||||
|
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("description");
|
||||
expect(labels).toContain("required");
|
||||
expect(labels).toContain("default");
|
||||
expect(labels).toContain("deprecationMessage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("document type routing", () => {
|
||||
it("routes action.yml to action completion", async () => {
|
||||
const [doc, position] = createActionDocument(`n|`, "file:///my-repo/action.yml");
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("name");
|
||||
// Should NOT contain workflow-specific keys
|
||||
expect(labels).not.toContain("on");
|
||||
expect(labels).not.toContain("jobs");
|
||||
});
|
||||
|
||||
it("does not route workflow files to action completion", async () => {
|
||||
const doc = TextDocument.create("file:///repo/.github/workflows/ci.yml", "yaml", 1, `o`);
|
||||
const completions = await complete(doc, {line: 0, character: 1});
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("on");
|
||||
expect(labels).toContain("jobs");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions";
|
||||
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
||||
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
|
||||
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
||||
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
|
||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
@@ -15,16 +17,23 @@ import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-sch
|
||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
import {getContext, Mode} from "./context-providers/default.js";
|
||||
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
|
||||
import {ActionContext, getActionContext} from "./context/action-context.js";
|
||||
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
|
||||
import {validatorFunctions} from "./expression-validation/functions.js";
|
||||
import {error} from "./log.js";
|
||||
import {detectDocumentType} from "./utils/document-type.js";
|
||||
import {isPotentiallyExpression} from "./utils/expression-detection.js";
|
||||
import {findToken} from "./utils/find-token.js";
|
||||
import {guessIndentation} from "./utils/indentation-guesser.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {isPlaceholder, transform} from "./utils/transform.js";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {
|
||||
getOrConvertActionTemplate,
|
||||
getOrConvertWorkflowTemplate,
|
||||
getOrParseAction,
|
||||
getOrParseWorkflow
|
||||
} from "./utils/workflow-cache.js";
|
||||
import {Value, ValueProviderConfig} from "./value-providers/config.js";
|
||||
import {defaultValueProviders} from "./value-providers/default.js";
|
||||
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
|
||||
@@ -68,45 +77,78 @@ export async function complete(
|
||||
content: newDoc.getText()
|
||||
};
|
||||
|
||||
const parsedWorkflow = fetchOrParseWorkflow(file, textDocument.uri, true);
|
||||
if (!parsedWorkflow.value) {
|
||||
// Determine document type - unknown defaults to workflow (backwards compatibility)
|
||||
const isAction = detectDocumentType(textDocument.uri) === "action";
|
||||
|
||||
// Parse the document
|
||||
const parsedTemplate = isAction
|
||||
? getOrParseAction(file, textDocument.uri, true)
|
||||
: getOrParseWorkflow(file, textDocument.uri, true);
|
||||
if (!parsedTemplate.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = await fetchOrConvertWorkflowTemplate(
|
||||
parsedWorkflow.context,
|
||||
parsedWorkflow.value,
|
||||
textDocument.uri,
|
||||
config,
|
||||
{
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
}
|
||||
);
|
||||
const schema = isAction ? getActionSchema() : getWorkflowSchema();
|
||||
const {token, keyToken, parent, path} = findToken(newPos, parsedTemplate.value);
|
||||
|
||||
const {token, keyToken, parent, path} = findToken(newPos, parsedWorkflow.value);
|
||||
const workflowContext = getWorkflowContext(textDocument.uri, template, path);
|
||||
// Build context for position-aware completions (e.g., steps.*, needs.*, inputs.*)
|
||||
let workflowContext: WorkflowContext | undefined;
|
||||
let actionContext: ActionContext | undefined;
|
||||
if (isAction) {
|
||||
const actionTemplate = getOrConvertActionTemplate(
|
||||
parsedTemplate.context,
|
||||
parsedTemplate.value,
|
||||
textDocument.uri,
|
||||
{errorPolicy: ErrorPolicy.TryConversion},
|
||||
true
|
||||
);
|
||||
actionContext = getActionContext(textDocument.uri, actionTemplate, path);
|
||||
} else {
|
||||
const workflowTemplate = await getOrConvertWorkflowTemplate(
|
||||
parsedTemplate.context,
|
||||
parsedTemplate.value,
|
||||
textDocument.uri,
|
||||
config,
|
||||
{
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
},
|
||||
true
|
||||
);
|
||||
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const context = await getContext(allowedContext, config?.contextProviderConfig, workflowContext, Mode.Completion);
|
||||
// Expression completions
|
||||
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const context = isAction
|
||||
? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion)
|
||||
: await getWorkflowExpressionContext(
|
||||
allowedContext,
|
||||
config?.contextProviderConfig,
|
||||
workflowContext,
|
||||
Mode.Completion
|
||||
);
|
||||
|
||||
return getExpressionCompletionItems(token, context, newPos);
|
||||
}
|
||||
return getExpressionCompletionItems(token, context, newPos);
|
||||
}
|
||||
|
||||
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
|
||||
const indentString = " ".repeat(indentation.tabSize);
|
||||
|
||||
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
|
||||
// YAML key/value completions
|
||||
const values = await getValues(
|
||||
token,
|
||||
keyToken,
|
||||
parent,
|
||||
config?.valueProviderConfig,
|
||||
workflowContext,
|
||||
indentString,
|
||||
schema
|
||||
);
|
||||
|
||||
// Add escape hatch completions when completing an empty scalar value for a one-of field.
|
||||
// These provide a way out of "dead end" situations where no scalar completions exist
|
||||
// but alternative structural forms (list, mapping) are available.
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos);
|
||||
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
||||
values.push(...escapeHatches);
|
||||
|
||||
// Figure out what text to replace when the user picks a completion.
|
||||
@@ -136,6 +178,7 @@ export async function complete(
|
||||
}
|
||||
}
|
||||
|
||||
// Convert values to LSP CompletionItems
|
||||
return values.map(value => {
|
||||
const newText = value.insertText || value.label;
|
||||
|
||||
@@ -182,8 +225,9 @@ async function getValues(
|
||||
keyToken: TemplateToken | null,
|
||||
parent: TemplateToken | null,
|
||||
valueProviderConfig: ValueProviderConfig | undefined,
|
||||
workflowContext: WorkflowContext,
|
||||
indentation: string
|
||||
workflowContext: WorkflowContext | undefined,
|
||||
indentation: string,
|
||||
schema: TemplateSchema
|
||||
): Promise<Value[]> {
|
||||
if (!parent) {
|
||||
return [];
|
||||
@@ -194,20 +238,23 @@ async function getValues(
|
||||
// Use the value providers from the parent if the current key is null
|
||||
const valueProviderToken = keyToken || parent;
|
||||
|
||||
const customValueProvider =
|
||||
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
|
||||
if (customValueProvider) {
|
||||
const customValues = await customValueProvider.get(workflowContext, existingValues);
|
||||
if (customValues) {
|
||||
return filterAndSortCompletionOptions(customValues, existingValues);
|
||||
// Value providers require workflow context - only use them for workflows
|
||||
if (workflowContext) {
|
||||
const customValueProvider =
|
||||
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
|
||||
if (customValueProvider) {
|
||||
const customValues = await customValueProvider.get(workflowContext, existingValues);
|
||||
if (customValues) {
|
||||
return filterAndSortCompletionOptions(customValues, existingValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultValueProvider =
|
||||
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
|
||||
if (defaultValueProvider) {
|
||||
const values = await defaultValueProvider.get(workflowContext, existingValues);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
const defaultValueProvider =
|
||||
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
|
||||
if (defaultValueProvider) {
|
||||
const values = await defaultValueProvider.get(workflowContext, existingValues);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the definition if there are no value providers
|
||||
@@ -224,7 +271,8 @@ async function getValues(
|
||||
def,
|
||||
indentation,
|
||||
keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent,
|
||||
tokenStructure
|
||||
tokenStructure,
|
||||
schema
|
||||
);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
}
|
||||
@@ -284,7 +332,8 @@ function getEscapeHatchCompletions(
|
||||
token: TemplateToken | null,
|
||||
keyToken: TemplateToken | null,
|
||||
indentation: string,
|
||||
position: Position
|
||||
position: Position,
|
||||
schema: TemplateSchema
|
||||
): Value[] {
|
||||
// Only show escape hatches when value is empty
|
||||
const tokenStructure = getTokenStructure(token);
|
||||
@@ -299,7 +348,6 @@ function getEscapeHatchCompletions(
|
||||
|
||||
// Determine which structural types are available from the definition
|
||||
const def = keyToken.definition;
|
||||
const schema = getWorkflowSchema();
|
||||
const buckets = {
|
||||
sequence: false,
|
||||
mapping: false
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getContext, Mode} from "./default.js";
|
||||
import {getWorkflowExpressionContext, Mode} from "./default.js";
|
||||
|
||||
describe("getContext", () => {
|
||||
describe("getWorkflowExpressionContext", () => {
|
||||
const emptyWorkflowContext: WorkflowContext = {
|
||||
uri: "test.yaml",
|
||||
template: undefined
|
||||
@@ -10,7 +10,7 @@ describe("getContext", () => {
|
||||
|
||||
describe("when no contextProviderConfig is provided", () => {
|
||||
it("should mark secrets context as incomplete", async () => {
|
||||
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||
expect(secretsContext).toBeDefined();
|
||||
@@ -18,7 +18,7 @@ describe("getContext", () => {
|
||||
});
|
||||
|
||||
it("should mark vars context as incomplete", async () => {
|
||||
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||
expect(varsContext).toBeDefined();
|
||||
@@ -26,7 +26,12 @@ describe("getContext", () => {
|
||||
});
|
||||
|
||||
it("should not mark other contexts as incomplete", async () => {
|
||||
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(
|
||||
["env", "github"],
|
||||
undefined,
|
||||
emptyWorkflowContext,
|
||||
Mode.Validation
|
||||
);
|
||||
|
||||
const envContext = result.get("env") as DescriptionDictionary;
|
||||
const githubContext = result.get("github") as DescriptionDictionary;
|
||||
@@ -48,7 +53,7 @@ describe("getContext", () => {
|
||||
getContext: () => Promise.resolve(providedContext)
|
||||
};
|
||||
|
||||
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets");
|
||||
expect(secretsContext).toBe(providedContext);
|
||||
@@ -63,7 +68,7 @@ describe("getContext", () => {
|
||||
getContext: () => Promise.resolve(providedContext)
|
||||
};
|
||||
|
||||
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars");
|
||||
expect(varsContext).toBe(providedContext);
|
||||
@@ -77,7 +82,7 @@ describe("getContext", () => {
|
||||
getContext: () => Promise.resolve(undefined)
|
||||
};
|
||||
|
||||
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||
expect(secretsContext.complete).toBe(false);
|
||||
@@ -88,7 +93,7 @@ describe("getContext", () => {
|
||||
getContext: () => Promise.resolve(undefined)
|
||||
};
|
||||
|
||||
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||
expect(varsContext.complete).toBe(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {Kind} from "@actions/expressions/data/expressiondata";
|
||||
import {ActionContext, getActionInputs, getActionStepIdsBefore} from "../context/action-context.js";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {ContextProviderConfig} from "./config.js";
|
||||
import {getDescription, RootContext} from "./descriptions.js";
|
||||
@@ -12,7 +13,6 @@ import {getMatrixContext} from "./matrix.js";
|
||||
import {getNeedsContext} from "./needs.js";
|
||||
import {getSecretsContext} from "./secrets.js";
|
||||
import {getStepsContext} from "./steps.js";
|
||||
import {getStrategyContext} from "./strategy.js";
|
||||
|
||||
// ContextValue is the type of the value returned by a context provider
|
||||
// Null indicates that the context provider doesn't have any value to provide
|
||||
@@ -24,10 +24,13 @@ export enum Mode {
|
||||
Hover
|
||||
}
|
||||
|
||||
export async function getContext(
|
||||
/**
|
||||
* Build expression context for workflow files (e.g., github.*, steps.*, needs.*)
|
||||
*/
|
||||
export async function getWorkflowExpressionContext(
|
||||
names: string[],
|
||||
config: ContextProviderConfig | undefined,
|
||||
workflowContext: WorkflowContext,
|
||||
workflowContext: WorkflowContext | undefined,
|
||||
mode: Mode
|
||||
): Promise<DescriptionDictionary> {
|
||||
const context = new DescriptionDictionary();
|
||||
@@ -41,7 +44,9 @@ export async function getContext(
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
|
||||
const remoteValue = workflowContext
|
||||
? await config?.getContext(contextName, value, workflowContext, mode)
|
||||
: undefined;
|
||||
if (remoteValue) {
|
||||
value = remoteValue;
|
||||
} else if (contextName === "secrets" || contextName === "vars") {
|
||||
@@ -57,61 +62,198 @@ export async function getContext(
|
||||
return context;
|
||||
}
|
||||
|
||||
function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: Mode): ContextValue | undefined {
|
||||
/**
|
||||
* Maps context name to its provider (e.g., "steps" -> getStepsContext)
|
||||
*/
|
||||
function getDefaultContext(
|
||||
name: string,
|
||||
workflowContext: WorkflowContext | undefined,
|
||||
mode: Mode
|
||||
): ContextValue | undefined {
|
||||
switch (name) {
|
||||
case "env":
|
||||
return getEnvContext(workflowContext);
|
||||
return workflowContext ? getEnvContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "github":
|
||||
return getGithubContext(workflowContext, mode);
|
||||
|
||||
case "inputs":
|
||||
return getInputsContext(workflowContext);
|
||||
return workflowContext ? getInputsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "reusableWorkflowJob":
|
||||
case "job":
|
||||
return getJobContext(workflowContext);
|
||||
return workflowContext ? getJobContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "jobs":
|
||||
return getJobsContext(workflowContext);
|
||||
return workflowContext ? getJobsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "matrix":
|
||||
return getMatrixContext(workflowContext, mode);
|
||||
return workflowContext ? getMatrixContext(workflowContext, mode) : new DescriptionDictionary();
|
||||
|
||||
case "needs":
|
||||
return getNeedsContext(workflowContext);
|
||||
return workflowContext ? getNeedsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "runner":
|
||||
return objectToDictionary({
|
||||
arch: "X64",
|
||||
debug: "1",
|
||||
environment: "github-hosted",
|
||||
name: "GitHub Actions 2",
|
||||
os: "Linux",
|
||||
temp: "/home/runner/work/_temp",
|
||||
tool_cache: "/opt/hostedtoolcache",
|
||||
workspace: "/home/runner/work/repo"
|
||||
});
|
||||
return getRunnerContext();
|
||||
|
||||
case "secrets":
|
||||
return getSecretsContext(workflowContext, mode);
|
||||
return workflowContext ? getSecretsContext(workflowContext, mode) : new DescriptionDictionary();
|
||||
|
||||
case "steps":
|
||||
return getStepsContext(workflowContext);
|
||||
return workflowContext ? getStepsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "strategy":
|
||||
return getStrategyContext(workflowContext);
|
||||
return getStrategyContext();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function objectToDictionary(object: {[key: string]: string}): DescriptionDictionary {
|
||||
const dictionary = new DescriptionDictionary();
|
||||
/**
|
||||
* Returns the strategy context with default values (fail-fast, job-index, etc.)
|
||||
*/
|
||||
function getStrategyContext(): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
|
||||
return new DescriptionDictionary(
|
||||
{key: "fail-fast", value: new data.BooleanData(true), description: getDescription("strategy", "fail-fast")},
|
||||
{key: "job-index", value: new data.NumberData(0), description: getDescription("strategy", "job-index")},
|
||||
{key: "job-total", value: new data.NumberData(1), description: getDescription("strategy", "job-total")},
|
||||
{key: "max-parallel", value: new data.NumberData(1), description: getDescription("strategy", "max-parallel")}
|
||||
);
|
||||
}
|
||||
|
||||
for (const key in object) {
|
||||
dictionary.add(key, new data.StringData(object[key]));
|
||||
/**
|
||||
* Returns the runner context with environment info (arch, os, temp, workspace, etc.)
|
||||
*/
|
||||
function getRunnerContext(): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
|
||||
return new DescriptionDictionary(
|
||||
{key: "arch", value: new data.StringData("X64"), description: getDescription("runner", "arch")},
|
||||
{key: "debug", value: new data.StringData("1"), description: getDescription("runner", "debug")},
|
||||
{
|
||||
key: "environment",
|
||||
value: new data.StringData("github-hosted"),
|
||||
description: getDescription("runner", "environment")
|
||||
},
|
||||
{key: "name", value: new data.StringData("GitHub Actions 2"), description: getDescription("runner", "name")},
|
||||
{key: "os", value: new data.StringData("Linux"), description: getDescription("runner", "os")},
|
||||
{key: "temp", value: new data.StringData("/home/runner/work/_temp"), description: getDescription("runner", "temp")},
|
||||
{
|
||||
key: "tool_cache",
|
||||
value: new data.StringData("/opt/hostedtoolcache"),
|
||||
description: getDescription("runner", "tool_cache")
|
||||
},
|
||||
{
|
||||
key: "workspace",
|
||||
value: new data.StringData("/home/runner/work/repo"),
|
||||
description: getDescription("runner", "workspace")
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context for expression completion in action.yml files.
|
||||
* Actions have a more limited set of contexts available compared to workflows.
|
||||
*/
|
||||
export function getActionExpressionContext(
|
||||
names: string[],
|
||||
config: ContextProviderConfig | undefined,
|
||||
actionContext: ActionContext | undefined,
|
||||
mode: Mode
|
||||
): DescriptionDictionary {
|
||||
const context = new DescriptionDictionary();
|
||||
|
||||
for (const contextName of names) {
|
||||
const value = getDefaultActionContext(contextName, actionContext, mode);
|
||||
if (value) {
|
||||
context.add(contextName, value, getDescription(RootContext, contextName));
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps context name to its provider for action.yml files (e.g., "inputs" -> getActionInputsContext)
|
||||
*/
|
||||
function getDefaultActionContext(
|
||||
name: string,
|
||||
actionContext: ActionContext | undefined,
|
||||
mode: Mode
|
||||
): ContextValue | undefined {
|
||||
switch (name) {
|
||||
case "inputs":
|
||||
// Return empty dictionary if no context - still allows completion, just without specific input names
|
||||
return actionContext ? getActionInputsContext(actionContext) : new DescriptionDictionary();
|
||||
|
||||
case "steps":
|
||||
// Return empty dictionary if no context - still allows completion, just without specific step IDs
|
||||
return actionContext ? getActionStepsContext(actionContext) : new DescriptionDictionary();
|
||||
|
||||
case "github":
|
||||
// Use the same github context but without workflow-specific event info
|
||||
// Actions inherit the event context from the calling workflow at runtime
|
||||
return getGithubContext(undefined, mode);
|
||||
|
||||
case "runner":
|
||||
return getRunnerContext();
|
||||
|
||||
case "env":
|
||||
// Actions can access env but we don't have runtime values
|
||||
return new DescriptionDictionary();
|
||||
|
||||
case "job": {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||
const jobContext = new DescriptionDictionary();
|
||||
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
|
||||
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
|
||||
const containerContext = new DescriptionDictionary();
|
||||
containerContext.add("id", new data.StringData(""), getDescription("job", "container.id"));
|
||||
containerContext.add("network", new data.StringData(""), getDescription("job", "container.network"));
|
||||
jobContext.add("container", containerContext, getDescription("job", "container"));
|
||||
jobContext.add("services", new DescriptionDictionary(), getDescription("job", "services"));
|
||||
return jobContext;
|
||||
}
|
||||
|
||||
case "strategy":
|
||||
return getStrategyContext();
|
||||
|
||||
case "matrix":
|
||||
// Actions can access matrix context at runtime
|
||||
return new DescriptionDictionary();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inputs context for action files based on defined inputs
|
||||
*/
|
||||
function getActionInputsContext(actionContext: ActionContext): DescriptionDictionary {
|
||||
const dict = new DescriptionDictionary();
|
||||
const inputs = getActionInputs(actionContext.template);
|
||||
|
||||
for (const input of inputs) {
|
||||
dict.add(input.id, new data.StringData(""), input.description || "");
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get steps context for composite action files based on step IDs
|
||||
*/
|
||||
function getActionStepsContext(actionContext: ActionContext): DescriptionDictionary {
|
||||
const dict = new DescriptionDictionary();
|
||||
const stepIds = getActionStepIdsBefore(actionContext);
|
||||
|
||||
for (const stepId of stepIds) {
|
||||
const stepDict = new DescriptionDictionary();
|
||||
stepDict.add("outputs", new DescriptionDictionary(), getDescription("steps", "outputs"));
|
||||
stepDict.add("outcome", new data.StringData("success"), getDescription("steps", "outcome"));
|
||||
stepDict.add("conclusion", new data.StringData("success"), getDescription("steps", "conclusion"));
|
||||
dict.add(stepId, stepDict, `Step: ${stepId}`);
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
@@ -198,6 +198,35 @@
|
||||
"description": "The default working directory on the runner for steps, and the default location of your repository when using the [`checkout`](https://github.com/actions/checkout) action."
|
||||
}
|
||||
},
|
||||
"job": {
|
||||
"container": {
|
||||
"description": "Information about the job's container. For more information about containers, see \"[Running jobs in a container](https://docs.github.com/actions/using-jobs/running-jobs-in-a-container).\""
|
||||
},
|
||||
"container.id": {
|
||||
"description": "The ID of the container."
|
||||
},
|
||||
"container.network": {
|
||||
"description": "The ID of the container network. The runner creates the network used by all containers in a job."
|
||||
},
|
||||
"services": {
|
||||
"description": "The service containers created for a job. For more information about service containers, see \"[Using service containers](https://docs.github.com/actions/using-containerized-services/about-service-containers).\""
|
||||
},
|
||||
"services.<service_id>.id": {
|
||||
"description": "The ID of the service container."
|
||||
},
|
||||
"services.<service_id>.network": {
|
||||
"description": "The ID of the service container network. The runner creates the network used by all containers in a job."
|
||||
},
|
||||
"services.<service_id>.ports": {
|
||||
"description": "The exposed ports of the service container."
|
||||
},
|
||||
"status": {
|
||||
"description": "The current status of the job. Possible values are `success`, `failure`, or `cancelled`."
|
||||
},
|
||||
"check_run_id": {
|
||||
"description": "The unique identifier of the check run for this job."
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"GITHUB_TOKEN": {
|
||||
"description": "Automatically created token for each workflow run. For more information, see \"[Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\""
|
||||
|
||||
@@ -7,7 +7,10 @@ import {getDescription} from "./descriptions.js";
|
||||
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads.js";
|
||||
import {getInputsContext} from "./inputs.js";
|
||||
|
||||
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
|
||||
/**
|
||||
* Returns the github context with properties like actor, ref, sha, event, etc.
|
||||
*/
|
||||
export function getGithubContext(workflowContext: WorkflowContext | undefined, mode: Mode): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
const keys = [
|
||||
"action",
|
||||
@@ -73,7 +76,10 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
);
|
||||
}
|
||||
|
||||
function getEventContext(workflowContext: WorkflowContext, mode: Mode): ExpressionData {
|
||||
/**
|
||||
* Builds the github.event context based on workflow trigger configuration.
|
||||
*/
|
||||
function getEventContext(workflowContext: WorkflowContext | undefined, mode: Mode): ExpressionData {
|
||||
const d = new DescriptionDictionary();
|
||||
const eventsConfig = workflowContext?.template?.events;
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getJobContext} from "./job.js";
|
||||
|
||||
function stringToToken(value: string): StringToken {
|
||||
return new StringToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
describe("job context", () => {
|
||||
it("returns empty context when no job", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
// When there's no job, context is empty
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns status and check_run_id when job has no container or services", () => {
|
||||
const workflowContext = {job: {}} as WorkflowContext;
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
expect(context.get("status")).toBeDefined();
|
||||
expect(context.get("check_run_id")).toBeDefined();
|
||||
expect(context.get("container")).toBeUndefined();
|
||||
expect(context.get("services")).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("container context", () => {
|
||||
it("includes container with id and network when container is defined", () => {
|
||||
const containerToken = new MappingToken(undefined, undefined, undefined);
|
||||
containerToken.add(stringToToken("image"), stringToToken("node:18"));
|
||||
|
||||
const workflowContext = {
|
||||
job: {container: containerToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const container = context.get("container");
|
||||
|
||||
expect(container).toBeDefined();
|
||||
if (!container) return;
|
||||
expect(isDescriptionDictionary(container)).toBe(true);
|
||||
|
||||
const containerDict = container as DescriptionDictionary;
|
||||
expect(containerDict.get("id")).toBeDefined();
|
||||
expect(containerDict.get("network")).toBeDefined();
|
||||
expect(containerDict.get("ports")).toBeUndefined(); // job container has no ports
|
||||
});
|
||||
|
||||
it("container has descriptions", () => {
|
||||
const containerToken = new MappingToken(undefined, undefined, undefined);
|
||||
containerToken.add(stringToToken("image"), stringToToken("node:18"));
|
||||
|
||||
const workflowContext = {
|
||||
job: {container: containerToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
const containerDescription = context.getDescription("container");
|
||||
expect(containerDescription).toBeDefined();
|
||||
|
||||
const containerDict = context.get("container") as DescriptionDictionary;
|
||||
expect(containerDict.getDescription("id")).toBeDefined();
|
||||
expect(containerDict.getDescription("network")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("services context", () => {
|
||||
it("includes services with id, network, and ports", () => {
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const services = context.get("services");
|
||||
|
||||
expect(services).toBeDefined();
|
||||
if (!services) return;
|
||||
expect(isDescriptionDictionary(services)).toBe(true);
|
||||
|
||||
const servicesDict = services as DescriptionDictionary;
|
||||
const redis = servicesDict.get("redis");
|
||||
expect(redis).toBeDefined();
|
||||
if (!redis) return;
|
||||
expect(isDescriptionDictionary(redis)).toBe(true);
|
||||
|
||||
const redisDict = redis as DescriptionDictionary;
|
||||
expect(redisDict.get("id")).toBeDefined();
|
||||
expect(redisDict.get("network")).toBeDefined();
|
||||
expect(redisDict.get("ports")).toBeDefined(); // services have ports
|
||||
});
|
||||
|
||||
it("parses service ports in host:container format", () => {
|
||||
const portsSequence = new SequenceToken(undefined, undefined, undefined);
|
||||
portsSequence.add(stringToToken("6379:6379"));
|
||||
portsSequence.add(stringToToken("8080:80"));
|
||||
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
redisToken.add(stringToToken("ports"), portsSequence);
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const services = context.get("services") as DescriptionDictionary;
|
||||
const redis = services.get("redis") as DescriptionDictionary;
|
||||
const ports = redis.get("ports") as DescriptionDictionary;
|
||||
|
||||
// Container ports should be the keys (second part of host:container)
|
||||
expect(ports.get("6379")).toBeDefined();
|
||||
expect(ports.get("80")).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses service ports in single port format", () => {
|
||||
const portsSequence = new SequenceToken(undefined, undefined, undefined);
|
||||
portsSequence.add(stringToToken("6379"));
|
||||
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
redisToken.add(stringToToken("ports"), portsSequence);
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const services = context.get("services") as DescriptionDictionary;
|
||||
const redis = services.get("redis") as DescriptionDictionary;
|
||||
const ports = redis.get("ports") as DescriptionDictionary;
|
||||
|
||||
// Single port format uses the port as the key
|
||||
expect(ports.get("6379")).toBeDefined();
|
||||
});
|
||||
|
||||
it("services have descriptions", () => {
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
const servicesDescription = context.getDescription("services");
|
||||
expect(servicesDescription).toBeDefined();
|
||||
|
||||
const services = context.get("services") as DescriptionDictionary;
|
||||
const redis = services.get("redis") as DescriptionDictionary;
|
||||
expect(redis.getDescription("id")).toBeDefined();
|
||||
expect(redis.getDescription("network")).toBeDefined();
|
||||
expect(redis.getDescription("ports")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,11 @@ import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {isMapping, isSequence} from "@actions/workflow-parser";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getDescription} from "./descriptions.js";
|
||||
|
||||
/**
|
||||
* Returns the job context with container, services, status, and check_run_id.
|
||||
*/
|
||||
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||
const jobContext = new DescriptionDictionary();
|
||||
@@ -15,7 +19,7 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
||||
const jobContainer = job.container;
|
||||
if (jobContainer && isMapping(jobContainer)) {
|
||||
const containerContext = createContainerContext(jobContainer, false);
|
||||
jobContext.add("container", containerContext);
|
||||
jobContext.add("container", containerContext, getDescription("job", "container"));
|
||||
}
|
||||
|
||||
// Services
|
||||
@@ -29,42 +33,48 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
||||
const serviceContext = createContainerContext(service.value, true);
|
||||
servicesContext.add(service.key.toString(), serviceContext);
|
||||
}
|
||||
jobContext.add("services", servicesContext);
|
||||
jobContext.add("services", servicesContext, getDescription("job", "services"));
|
||||
}
|
||||
|
||||
// Status
|
||||
jobContext.add("status", new data.Null());
|
||||
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
|
||||
|
||||
// Check run ID
|
||||
jobContext.add("check_run_id", new data.Null());
|
||||
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
|
||||
|
||||
return jobContext;
|
||||
}
|
||||
|
||||
function createContainerContext(container: MappingToken, isServices: boolean): data.Dictionary {
|
||||
const containerContext = new data.Dictionary();
|
||||
for (const {key, value} of container) {
|
||||
if (isSequence(value)) {
|
||||
// service ports are the only thing that is part of the job context
|
||||
if (key.toString() !== "ports") {
|
||||
continue;
|
||||
}
|
||||
const ports = new data.Dictionary();
|
||||
for (const item of value) {
|
||||
// We can determine the context mapping fully only if the port is defined
|
||||
// as a mapping (i.e. <port1>:<port2>), single ports are assigned randomly
|
||||
const portParts = item.toString().split(":");
|
||||
if (isServices && portParts.length === 2) {
|
||||
ports.add(portParts[1], new data.StringData(portParts[0]));
|
||||
} else {
|
||||
// If the port isn't a mapping, just use null
|
||||
ports.add(portParts[0], new data.Null());
|
||||
function createContainerContext(container: MappingToken, isServices: boolean): DescriptionDictionary {
|
||||
const containerContext = new DescriptionDictionary();
|
||||
|
||||
// id and network are always available
|
||||
containerContext.add(
|
||||
"id",
|
||||
new data.StringData(""),
|
||||
getDescription("job", isServices ? "services.<service_id>.id" : "container.id")
|
||||
);
|
||||
containerContext.add(
|
||||
"network",
|
||||
new data.StringData(""),
|
||||
getDescription("job", isServices ? "services.<service_id>.network" : "container.network")
|
||||
);
|
||||
|
||||
// ports are only available for service containers (not job container)
|
||||
if (isServices) {
|
||||
const ports = new DescriptionDictionary();
|
||||
for (const {key, value} of container) {
|
||||
if (key.toString() === "ports" && isSequence(value)) {
|
||||
for (const item of value) {
|
||||
const portParts = item.toString().split(":");
|
||||
// The key is the container port (second part if host:container format)
|
||||
const containerPort = portParts.length === 2 ? portParts[1] : portParts[0];
|
||||
ports.add(containerPort, new data.StringData(""));
|
||||
}
|
||||
}
|
||||
containerContext.add(key.toString(), ports);
|
||||
}
|
||||
containerContext.add("ports", ports, getDescription("job", "services.<service_id>.ports"));
|
||||
}
|
||||
containerContext.add("id", new data.Null());
|
||||
containerContext.add("network", new data.Null());
|
||||
|
||||
return containerContext;
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import {data} from "@actions/expressions";
|
||||
import {Job} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {BooleanToken} from "@actions/workflow-parser/templates/tokens/boolean-token";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {NumberToken} from "@actions/workflow-parser/templates/tokens/number-token";
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getStrategyContext} from "./strategy.js";
|
||||
|
||||
function stringToToken(value: string) {
|
||||
return new StringToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function boolToToken(value: boolean) {
|
||||
return new BooleanToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function numberToToken(value: number) {
|
||||
return new NumberToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function contextFromStrategy(strategy?: TemplateToken) {
|
||||
return {
|
||||
job: {
|
||||
strategy: strategy
|
||||
}
|
||||
} as WorkflowContext;
|
||||
}
|
||||
|
||||
describe("strategy context", () => {
|
||||
describe("no strategy defined", () => {
|
||||
it("returns defaults when job is undefined", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("returns defaults when strategy is undefined", () => {
|
||||
const job = {} as Job;
|
||||
const workflowContext = {job} as WorkflowContext;
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("returns defaults when strategy is not a mapping", () => {
|
||||
const workflowContext = contextFromStrategy(stringToToken("hello"));
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("strategy defined with partial properties", () => {
|
||||
it("uses specified fail-fast, defaults for others", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("fail-fast"), boolToToken(false));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("uses specified max-parallel, defaults for others", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("max-parallel"), numberToToken(5));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(5));
|
||||
});
|
||||
|
||||
it("only has matrix defined, all strategy properties use defaults", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
const matrix = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("matrix"), matrix);
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("strategy with all properties defined", () => {
|
||||
it("uses all specified values", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("fail-fast"), boolToToken(false));
|
||||
strategy.add(stringToToken("max-parallel"), numberToToken(3));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
|
||||
// job-index and job-total are runtime values, not specified in YAML
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(3));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {isMapping, isScalar, isString} from "@actions/workflow-parser";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {scalarToData} from "../utils/scalar-to-data.js";
|
||||
|
||||
// Default strategy values when no strategy block is defined
|
||||
const DEFAULT_STRATEGY = {
|
||||
"fail-fast": new data.BooleanData(true),
|
||||
"job-index": new data.NumberData(0),
|
||||
"job-total": new data.NumberData(1),
|
||||
"max-parallel": new data.NumberData(1)
|
||||
};
|
||||
|
||||
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
|
||||
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
|
||||
|
||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||
if (!strategy || !isMapping(strategy)) {
|
||||
// No strategy defined - return defaults that match runtime behavior
|
||||
return new DescriptionDictionary(
|
||||
...keys.map(key => {
|
||||
return {key, value: DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const strategyContext = new DescriptionDictionary();
|
||||
for (const pair of strategy) {
|
||||
if (!isString(pair.key)) {
|
||||
continue;
|
||||
}
|
||||
if (!keys.includes(pair.key.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = isScalar(pair.value) ? scalarToData(pair.value) : new data.Null();
|
||||
strategyContext.add(pair.key.value, value);
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (!strategyContext.get(key)) {
|
||||
// Use default value for missing properties
|
||||
strategyContext.add(key, DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]);
|
||||
}
|
||||
}
|
||||
|
||||
return strategyContext;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {ActionInputDefinition, ActionTemplate} from "@actions/workflow-parser/actions/action-template";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
|
||||
/**
|
||||
* Context information for an action.yml file, used to provide
|
||||
* expression completion with action-specific values.
|
||||
*/
|
||||
export interface ActionContext {
|
||||
uri: string;
|
||||
|
||||
/** The converted action template */
|
||||
template: ActionTemplate | undefined;
|
||||
|
||||
/** If the context is for a position within a composite step, this will be the step */
|
||||
step?: Step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context from a converted action template and token path.
|
||||
* Similar to getWorkflowContext but for action files.
|
||||
*/
|
||||
export function getActionContext(
|
||||
uri: string,
|
||||
template: ActionTemplate | undefined,
|
||||
tokenPath: TemplateToken[]
|
||||
): ActionContext {
|
||||
const context: ActionContext = {uri, template};
|
||||
if (!template) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// Only composite actions have steps
|
||||
if (template.runs?.using !== "composite") {
|
||||
return context;
|
||||
}
|
||||
|
||||
const compositeRuns = template.runs;
|
||||
if (!compositeRuns.steps?.length) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// Find the current step from the token path
|
||||
let stepsSequence: SequenceToken | undefined;
|
||||
let stepToken: MappingToken | undefined;
|
||||
|
||||
for (const token of tokenPath) {
|
||||
const defKey = token.definition?.key;
|
||||
if (defKey === "composite-steps" && token instanceof SequenceToken) {
|
||||
stepsSequence = token;
|
||||
} else if ((defKey === "run-step" || defKey === "uses-step") && isMapping(token)) {
|
||||
stepToken = token;
|
||||
}
|
||||
}
|
||||
|
||||
if (stepsSequence && stepToken) {
|
||||
context.step = findStep(compositeRuns.steps, stepsSequence, stepToken);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Step that corresponds to the given step token.
|
||||
*/
|
||||
function findStep(steps: Step[], stepsSequence: SequenceToken, stepToken: MappingToken): Step | undefined {
|
||||
// Find the step by matching index in the sequence
|
||||
let stepIndex = -1;
|
||||
for (let i = 0; i < stepsSequence.count; i++) {
|
||||
if (stepsSequence.get(i) === stepToken) {
|
||||
stepIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stepIndex === -1 || stepIndex >= steps.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return steps[stepIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get input definitions from the action template.
|
||||
*/
|
||||
export function getActionInputs(template: ActionTemplate | undefined): ActionInputDefinition[] {
|
||||
return template?.inputs ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get step IDs from composite action steps that appear before the current step.
|
||||
* This is used for `steps.<id>` context completion - you can only reference
|
||||
* steps that have already run.
|
||||
*/
|
||||
export function getActionStepIdsBefore(context: ActionContext): string[] {
|
||||
const template = context.template;
|
||||
if (!template || template.runs?.using !== "composite") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const compositeRuns = template.runs;
|
||||
const steps = compositeRuns.steps ?? [];
|
||||
const currentStep = context.step;
|
||||
|
||||
const stepIds: string[] = [];
|
||||
for (const step of steps) {
|
||||
// Stop when we reach the current step
|
||||
if (currentStep && step === currentStep) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Only include steps with explicit IDs
|
||||
if (step.id) {
|
||||
stepIds.push(step.id);
|
||||
}
|
||||
}
|
||||
|
||||
return stepIds;
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
|
||||
/**
|
||||
* Represents the contextual position within a workflow file.
|
||||
* Used to determine which expression contexts are available at a given location.
|
||||
*/
|
||||
export interface WorkflowContext {
|
||||
uri: string;
|
||||
|
||||
@@ -21,6 +25,12 @@ export interface WorkflowContext {
|
||||
step?: Step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a WorkflowContext by walking the token path to identify the current job and step.
|
||||
* @param uri - The URI of the workflow file
|
||||
* @param template - The parsed workflow template
|
||||
* @param tokenPath - The path of tokens from root to the current position
|
||||
*/
|
||||
export function getWorkflowContext(
|
||||
uri: string,
|
||||
template: WorkflowTemplate | undefined,
|
||||
@@ -73,6 +83,10 @@ export function getWorkflowContext(
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a Step by matching the step token's position in the steps sequence.
|
||||
* Steps may not have IDs, so we locate them by index rather than by identifier.
|
||||
*/
|
||||
function findStep(steps?: Step[], stepSequence?: SequenceToken, stepToken?: MappingToken): Step | undefined {
|
||||
if (!steps || !stepSequence || !stepToken) {
|
||||
return undefined;
|
||||
|
||||
@@ -3,6 +3,9 @@ import {DESCRIPTION} from "@actions/workflow-parser/templates/template-constants
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {TokenResult} from "../utils/find-token.js";
|
||||
|
||||
/**
|
||||
* Checks if the token is an input value in a reusable workflow job's `with:` block.
|
||||
*/
|
||||
export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
|
||||
return (
|
||||
tokenResult.parent?.definition?.key === "workflow-job-with" &&
|
||||
@@ -11,6 +14,11 @@ export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the description of an input from a called reusable workflow.
|
||||
* When a workflow calls another workflow with `uses:`, this fetches the input's
|
||||
* description from the called workflow's `workflow_call.inputs` definitions.
|
||||
*/
|
||||
export function getReusableWorkflowInputDescription(
|
||||
workflowContext: WorkflowContext,
|
||||
tokenResult: TokenResult
|
||||
|
||||
@@ -129,4 +129,31 @@ jobs:
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("links for actions in composite action", async () => {
|
||||
const input = `name: My Composite Action
|
||||
description: A composite action with nested actions
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- run: echo "Hello"
|
||||
shell: bash`;
|
||||
const result = await documentLinks(createDocument("action.yml", input), undefined);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].target).toBe("https://www.github.com/actions/checkout/tree/v4/");
|
||||
expect(result[0].tooltip).toBe("Open action on GitHub");
|
||||
expect(result[1].target).toBe("https://www.github.com/actions/setup-node/tree/v4/");
|
||||
});
|
||||
|
||||
it("no links for non-composite action", async () => {
|
||||
const input = `name: My Node Action
|
||||
description: A node action
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`;
|
||||
const result = await documentLinks(createDocument("action.yml", input), undefined);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,29 +6,82 @@ import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {DocumentLink} from "vscode-languageserver-types";
|
||||
import * as vscodeURI from "vscode-uri";
|
||||
import {actionUrl, parseActionReference} from "./action.js";
|
||||
import {isActionDocument} from "./utils/document-type.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {
|
||||
getOrConvertActionTemplate,
|
||||
getOrConvertWorkflowTemplate,
|
||||
getOrParseAction,
|
||||
getOrParseWorkflow
|
||||
} from "./utils/workflow-cache.js";
|
||||
|
||||
/**
|
||||
* Generates clickable links for action references and reusable workflows.
|
||||
*/
|
||||
export async function documentLinks(document: TextDocument, workspace: string | undefined): Promise<DocumentLink[]> {
|
||||
const file: File = {
|
||||
name: document.uri,
|
||||
content: document.getText()
|
||||
};
|
||||
|
||||
const parsedWorkflow = fetchOrParseWorkflow(file, document.uri);
|
||||
return isActionDocument(document.uri)
|
||||
? actionDocumentLinks(file, document.uri)
|
||||
: workflowDocumentLinks(file, document.uri, workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates clickable links for action references in action.yml files.
|
||||
*/
|
||||
function actionDocumentLinks(file: File, uri: string): DocumentLink[] {
|
||||
const parsedAction = getOrParseAction(file, uri);
|
||||
if (!parsedAction?.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = getOrConvertActionTemplate(parsedAction.context, parsedAction.value, uri, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
const links: DocumentLink[] = [];
|
||||
|
||||
// Only composite actions have steps
|
||||
if (template?.runs?.using !== "composite") {
|
||||
return links;
|
||||
}
|
||||
|
||||
const steps = template.runs.steps ?? [];
|
||||
for (const step of steps) {
|
||||
if ("uses" in step) {
|
||||
const actionRef = parseActionReference(step.uses.value);
|
||||
if (!actionRef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = actionUrl(actionRef);
|
||||
|
||||
links.push({
|
||||
range: mapRange(step.uses.range),
|
||||
target: url,
|
||||
tooltip: `Open action on GitHub`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates clickable links for action references and reusable workflows in workflow files.
|
||||
*/
|
||||
async function workflowDocumentLinks(file: File, uri: string, workspace: string | undefined): Promise<DocumentLink[]> {
|
||||
const parsedWorkflow = getOrParseWorkflow(file, uri);
|
||||
if (!parsedWorkflow?.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = await fetchOrConvertWorkflowTemplate(
|
||||
parsedWorkflow.context,
|
||||
parsedWorkflow.value,
|
||||
document.uri,
|
||||
undefined,
|
||||
{
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
}
|
||||
);
|
||||
const template = await getOrConvertWorkflowTemplate(parsedWorkflow.context, parsedWorkflow.value, uri, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
const links: DocumentLink[] = [];
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {convertWorkflowTemplate, parseWorkflow} from "@actions/workflow-parser";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {ContextProviderConfig} from "../context-providers/config.js";
|
||||
import {getContext, Mode} from "../context-providers/default.js";
|
||||
import {getWorkflowExpressionContext, Mode} from "../context-providers/default.js";
|
||||
import {getWorkflowContext} from "../context/workflow-context.js";
|
||||
import {validatorFunctions} from "../expression-validation/functions.js";
|
||||
import {nullTrace} from "../nulltrace.js";
|
||||
@@ -116,7 +116,12 @@ async function hoverExpression(input: string) {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
const workflowContext = getWorkflowContext(td.uri, template, []);
|
||||
const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion);
|
||||
const context = await getWorkflowExpressionContext(
|
||||
allowedContext,
|
||||
contextProviderConfig,
|
||||
workflowContext,
|
||||
Mode.Completion
|
||||
);
|
||||
|
||||
const l = new Lexer(td.getText());
|
||||
const lr = l.lex();
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {hover} from "./hover";
|
||||
|
||||
describe("hover action files", () => {
|
||||
function createActionDocument(
|
||||
content: string,
|
||||
uri = "file:///test/action.yml"
|
||||
): [TextDocument, {line: number; character: number}] {
|
||||
// Parse cursor position and remove the | character
|
||||
const cursorIndex = content.indexOf("|");
|
||||
if (cursorIndex === -1) {
|
||||
throw new Error("No cursor (|) found in content");
|
||||
}
|
||||
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
|
||||
const doc = TextDocument.create(uri, "yaml", 1, newContent);
|
||||
const position = doc.positionAt(cursorIndex);
|
||||
return [doc, position];
|
||||
}
|
||||
|
||||
describe("top-level keys", () => {
|
||||
it("shows description for name key", async () => {
|
||||
const [doc, position] = createActionDocument(`na|me: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("name");
|
||||
});
|
||||
|
||||
it("shows description for description key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
descrip|tion: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("description");
|
||||
});
|
||||
|
||||
it("shows description for runs key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
ru|ns:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("runs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs properties", () => {
|
||||
it("shows description for using key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
us|ing: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("runtime");
|
||||
});
|
||||
|
||||
it("shows description for main key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
ma|in: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inputs", () => {
|
||||
it("shows description for inputs section", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
inp|uts:
|
||||
my-input:
|
||||
description: A test input
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("input");
|
||||
});
|
||||
|
||||
it("shows description for required key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
inputs:
|
||||
my-input:
|
||||
description: A test input
|
||||
requ|ired: true
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("required");
|
||||
});
|
||||
|
||||
it("shows allowed context for default value", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
inputs:
|
||||
my-input:
|
||||
description: A test input
|
||||
def|ault: foo
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Input defaults can use expressions with github, strategy, matrix, job, runner contexts
|
||||
expect(result?.contents).toContain("github");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding", () => {
|
||||
it("shows description for branding section", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
brand|ing:
|
||||
icon: activity
|
||||
color: blue`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("brand");
|
||||
});
|
||||
|
||||
it("shows description for icon key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
branding:
|
||||
ic|on: activity
|
||||
color: blue`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("icon");
|
||||
});
|
||||
});
|
||||
|
||||
describe("document type routing", () => {
|
||||
it("routes action.yml to action hover", async () => {
|
||||
const [doc, position] = createActionDocument(
|
||||
`na|me: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`,
|
||||
"file:///my-repo/action.yml"
|
||||
);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not route workflow files to action hover", async () => {
|
||||
const doc = TextDocument.create(
|
||||
"file:///repo/.github/workflows/ci.yml",
|
||||
"yaml",
|
||||
1,
|
||||
`name: CI
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hello`
|
||||
);
|
||||
// Hovering over 'name' in a workflow file should give workflow-specific info
|
||||
const result = await hover(doc, {line: 0, character: 2});
|
||||
|
||||
// The workflow hover might not have description for workflow name,
|
||||
// but it should not crash
|
||||
expect(result === null || result.contents !== undefined).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+114
-58
@@ -1,6 +1,7 @@
|
||||
import {data, DescriptionDictionary, Parser} from "@actions/expressions";
|
||||
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
|
||||
import {Lexer} from "@actions/expressions/lexer";
|
||||
import {parseAction} from "@actions/workflow-parser/actions/action-parser";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
@@ -10,8 +11,9 @@ import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {Hover} from "vscode-languageserver-types";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
import {getContext, Mode} from "./context-providers/default.js";
|
||||
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
|
||||
import {getFunctionDescription} from "./context-providers/descriptions.js";
|
||||
import {ActionContext, getActionContext} from "./context/action-context.js";
|
||||
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
|
||||
import {
|
||||
getReusableWorkflowInputDescription,
|
||||
@@ -20,10 +22,12 @@ import {
|
||||
import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-pos.js";
|
||||
import {HoverVisitor} from "./expression-hover/visitor.js";
|
||||
import {info} from "./log.js";
|
||||
import {nullTrace} from "./nulltrace.js";
|
||||
import {isActionDocument} from "./utils/document-type.js";
|
||||
import {isPotentiallyExpression} from "./utils/expression-detection.js";
|
||||
import {findToken} from "./utils/find-token.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {getOrConvertActionTemplate, getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
|
||||
export type HoverConfig = {
|
||||
descriptionProvider?: DescriptionProvider;
|
||||
@@ -32,79 +36,125 @@ export type HoverConfig = {
|
||||
};
|
||||
|
||||
export type DescriptionProvider = {
|
||||
getDescription(context: WorkflowContext, token: TemplateToken, path: TemplateToken[]): Promise<string | undefined>;
|
||||
getDescription(
|
||||
context: WorkflowContext | ActionContext,
|
||||
token: TemplateToken,
|
||||
path: TemplateToken[]
|
||||
): Promise<string | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns hover information for the token at the given position.
|
||||
*/
|
||||
export async function hover(document: TextDocument, position: Position, config?: HoverConfig): Promise<Hover | null> {
|
||||
const file: File = {
|
||||
name: document.uri,
|
||||
content: document.getText()
|
||||
};
|
||||
|
||||
const parsedWorkflow = fetchOrParseWorkflow(file, document.uri);
|
||||
if (!parsedWorkflow?.value) {
|
||||
// Determine document type based on file path (action.yml vs workflow file)
|
||||
const isAction = isActionDocument(document.uri);
|
||||
|
||||
// Parse document
|
||||
const parsedTemplate = isAction ? parseAction(file, nullTrace) : getOrParseWorkflow(file, document.uri);
|
||||
if (!parsedTemplate?.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const template = await fetchOrConvertWorkflowTemplate(
|
||||
parsedWorkflow.context,
|
||||
parsedWorkflow.value,
|
||||
document.uri,
|
||||
config,
|
||||
{
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
|
||||
}
|
||||
);
|
||||
|
||||
const tokenResult = findToken(position, parsedWorkflow.value);
|
||||
// Find the token at the cursor position
|
||||
const tokenResult = findToken(position, parsedTemplate.value);
|
||||
const {token, keyToken, parent} = tokenResult;
|
||||
const tokenDefinitionInfo = (keyToken || parent || token)?.definitionInfo;
|
||||
|
||||
const workflowContext = getWorkflowContext(document.uri, template, tokenResult.path);
|
||||
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 context = await getContext(namedContexts, config?.contextProviderConfig, workflowContext, Mode.Completion);
|
||||
|
||||
for (const func of functions) {
|
||||
func.description = getFunctionDescription(func.name);
|
||||
}
|
||||
|
||||
const exprPos = mapToExpressionPos(token, position);
|
||||
if (exprPos) {
|
||||
return expressionHover(exprPos, context, namedContexts, functions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!token?.definition) {
|
||||
// Early exit if there's nothing to provide hover for
|
||||
const hoverToken = token || keyToken;
|
||||
const isExpressionHover =
|
||||
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token));
|
||||
if (!isExpressionHover && !hoverToken?.definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
info(`Calculating hover for token with definition ${token.definition.key}`);
|
||||
// Build document context (jobs, steps, inputs, etc.) from the parsed template
|
||||
const documentContext = isAction
|
||||
? getActionContext(
|
||||
document.uri,
|
||||
getOrConvertActionTemplate(parsedTemplate.context, parsedTemplate.value, document.uri, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
}),
|
||||
tokenResult.path
|
||||
)
|
||||
: getWorkflowContext(
|
||||
document.uri,
|
||||
await getOrConvertWorkflowTemplate(parsedTemplate.context, parsedTemplate.value, document.uri, config, {
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
|
||||
}),
|
||||
tokenResult.path
|
||||
);
|
||||
|
||||
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
||||
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
|
||||
description = appendContext(description, token.definitionInfo?.allowedContext);
|
||||
return {
|
||||
contents: description,
|
||||
range: mapRange(token.range)
|
||||
} satisfies Hover;
|
||||
// Expression hover
|
||||
if (isExpressionHover) {
|
||||
info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`);
|
||||
|
||||
const allowedContext = tokenDefinitionInfo.allowedContext || [];
|
||||
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||
|
||||
// Build expression context with named contexts (github, env, etc.) and their descriptions
|
||||
const expressionContext = isAction
|
||||
? getActionExpressionContext(
|
||||
namedContexts,
|
||||
config?.contextProviderConfig,
|
||||
documentContext as ActionContext,
|
||||
Mode.Hover
|
||||
)
|
||||
: await getWorkflowExpressionContext(
|
||||
namedContexts,
|
||||
config?.contextProviderConfig,
|
||||
documentContext as WorkflowContext,
|
||||
Mode.Hover
|
||||
);
|
||||
|
||||
// Populate function descriptions for hover display
|
||||
for (const func of functions) {
|
||||
func.description = getFunctionDescription(func.name);
|
||||
}
|
||||
|
||||
// Convert document position to expression-relative position
|
||||
const exprPos = mapToExpressionPos(token, position);
|
||||
if (exprPos) {
|
||||
// Find the expression element at the cursor and return its description
|
||||
return expressionHover(exprPos, expressionContext, namedContexts, functions);
|
||||
}
|
||||
}
|
||||
|
||||
let description = await getDescription(config, workflowContext, token, tokenResult.path);
|
||||
description = appendContext(description, token.definitionInfo?.allowedContext);
|
||||
if (!hoverToken?.definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Non-expression hover: show the schema description for the YAML key or value
|
||||
info(`Calculating hover for token with definition ${hoverToken.definition.key}`);
|
||||
|
||||
let description: string;
|
||||
if (!isAction && tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
||||
// Reusable workflow call: fetch the called workflow's input descriptions
|
||||
description = getReusableWorkflowInputDescription(documentContext as WorkflowContext, tokenResult);
|
||||
} else {
|
||||
// Default: use custom provider or token's schema description
|
||||
description =
|
||||
(await getDescription(config, documentContext, hoverToken, tokenResult.path)) || hoverToken.description || "";
|
||||
}
|
||||
|
||||
// Return hover with description and available expression contexts
|
||||
return {
|
||||
contents: description,
|
||||
range: mapRange(token.range)
|
||||
contents: appendContext(description, hoverToken.definitionInfo?.allowedContext),
|
||||
range: mapRange(hoverToken.range)
|
||||
} satisfies Hover;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends available expression contexts and functions to a hover description.
|
||||
* For example: "Available expression contexts: `github`, `env`"
|
||||
*/
|
||||
function appendContext(description: string, allowedContext?: string[]) {
|
||||
if (!allowedContext || allowedContext.length == 0) {
|
||||
return description;
|
||||
@@ -128,24 +178,30 @@ function appendContext(description: string, allowedContext?: string[]) {
|
||||
return `${description}${namedContextsString}${functionsString}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a custom description from the configured description provider.
|
||||
* Used to fetch rich descriptions like action input docs from GitHub repos.
|
||||
*/
|
||||
async function getDescription(
|
||||
config: HoverConfig | undefined,
|
||||
workflowContext: WorkflowContext,
|
||||
documentContext: WorkflowContext | ActionContext,
|
||||
token: TemplateToken,
|
||||
path: TemplateToken[]
|
||||
) {
|
||||
const defaultDescription = token.description || "";
|
||||
): Promise<string | undefined> {
|
||||
if (!config?.descriptionProvider) {
|
||||
return defaultDescription;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const description = await config.descriptionProvider.getDescription(workflowContext, token, path);
|
||||
return description || defaultDescription;
|
||||
return await config.descriptionProvider.getDescription(documentContext, token, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an expression and finds the element at the cursor position to show its description.
|
||||
* For example, hovering over `github.actor` shows "The login of the user that triggered the workflow".
|
||||
*/
|
||||
function expressionHover(
|
||||
exprPos: ExpressionPos,
|
||||
context: DescriptionDictionary,
|
||||
expressionContext: DescriptionDictionary,
|
||||
namedContexts: string[],
|
||||
functions: FunctionInfo[]
|
||||
): Hover | null {
|
||||
@@ -165,7 +221,7 @@ function expressionHover(
|
||||
call: () => new data.Null()
|
||||
});
|
||||
}
|
||||
const hv = new HoverVisitor(position, context, functionMap);
|
||||
const hv = new HoverVisitor(position, expressionContext, functionMap);
|
||||
const hoverResult = hv.hover(expr);
|
||||
if (!hoverResult) {
|
||||
return null;
|
||||
|
||||
@@ -4,7 +4,8 @@ import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {InlayHint, InlayHintKind} from "vscode-languageserver-types";
|
||||
import {fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {isActionDocument} from "./utils/document-type.js";
|
||||
import {getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
|
||||
/**
|
||||
* Returns inlay hints for a workflow document.
|
||||
@@ -15,12 +16,17 @@ import {fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
* @returns Array of inlay hints
|
||||
*/
|
||||
export function getInlayHints(document: TextDocument): InlayHint[] {
|
||||
// Inlay hints are only supported for workflow files (cron expressions)
|
||||
if (isActionDocument(document.uri)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const file: File = {
|
||||
name: document.uri,
|
||||
content: document.getText()
|
||||
};
|
||||
|
||||
const result = fetchOrParseWorkflow(file, document.uri);
|
||||
const result = getOrParseWorkflow(file, document.uri);
|
||||
if (!result?.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import {detectDocumentType, isActionDocument, isWorkflowDocument} from "./document-type";
|
||||
|
||||
describe("detectDocumentType", () => {
|
||||
describe("action files", () => {
|
||||
it("detects action.yml", () => {
|
||||
expect(detectDocumentType("/path/to/action.yml")).toBe("action");
|
||||
});
|
||||
|
||||
it("detects action.yaml", () => {
|
||||
expect(detectDocumentType("/path/to/action.yaml")).toBe("action");
|
||||
});
|
||||
|
||||
it("detects action.yml with case insensitivity", () => {
|
||||
expect(detectDocumentType("/path/to/ACTION.YML")).toBe("action");
|
||||
expect(detectDocumentType("/path/to/Action.Yaml")).toBe("action");
|
||||
});
|
||||
|
||||
it("detects nested action.yml", () => {
|
||||
expect(detectDocumentType("/repo/.github/actions/my-action/action.yml")).toBe("action");
|
||||
});
|
||||
|
||||
it("detects bare action.yml", () => {
|
||||
expect(detectDocumentType("action.yml")).toBe("action");
|
||||
});
|
||||
|
||||
it("handles Windows paths", () => {
|
||||
expect(detectDocumentType("C:\\Users\\me\\action.yml")).toBe("action");
|
||||
expect(detectDocumentType("C:\\repo\\.github\\actions\\my-action\\action.yml")).toBe("action");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow files", () => {
|
||||
it("detects workflow files in .github/workflows", () => {
|
||||
expect(detectDocumentType("/repo/.github/workflows/ci.yml")).toBe("workflow");
|
||||
expect(detectDocumentType("/repo/.github/workflows/build.yaml")).toBe("workflow");
|
||||
});
|
||||
|
||||
it("detects workflow files in .github/workflows-lab", () => {
|
||||
expect(detectDocumentType("/repo/.github/workflows-lab/ci.yml")).toBe("workflow");
|
||||
expect(detectDocumentType("/repo/.github/workflows-lab/build.yaml")).toBe("workflow");
|
||||
});
|
||||
|
||||
it("detects workflow files case insensitively", () => {
|
||||
expect(detectDocumentType("/repo/.github/workflows/CI.YML")).toBe("workflow");
|
||||
});
|
||||
|
||||
it("handles Windows paths for workflows", () => {
|
||||
expect(detectDocumentType("C:\\repo\\.github\\workflows\\ci.yml")).toBe("workflow");
|
||||
expect(detectDocumentType("C:\\repo\\.github\\workflows-lab\\ci.yml")).toBe("workflow");
|
||||
});
|
||||
|
||||
it("workflow path takes precedence over action filename", () => {
|
||||
// Edge case: action.yml inside .github/workflows should be treated as workflow
|
||||
expect(detectDocumentType("/repo/.github/workflows/action.yml")).toBe("workflow");
|
||||
expect(detectDocumentType("/repo/.github/workflows/action.yaml")).toBe("workflow");
|
||||
expect(detectDocumentType("/repo/.github/workflows-lab/action.yml")).toBe("workflow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown files", () => {
|
||||
it("returns unknown for other yaml files", () => {
|
||||
expect(detectDocumentType("/path/to/config.yml")).toBe("unknown");
|
||||
expect(detectDocumentType("/path/to/docker-compose.yaml")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns unknown for non-yaml files", () => {
|
||||
expect(detectDocumentType("/path/to/file.txt")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isActionDocument", () => {
|
||||
it("returns true for action files", () => {
|
||||
expect(isActionDocument("/path/to/action.yml")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for workflow files", () => {
|
||||
expect(isActionDocument("/repo/.github/workflows/ci.yml")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unknown files", () => {
|
||||
expect(isActionDocument("/path/to/config.yml")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkflowDocument", () => {
|
||||
it("returns true for workflow files", () => {
|
||||
expect(isWorkflowDocument("/repo/.github/workflows/ci.yml")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for action files", () => {
|
||||
expect(isWorkflowDocument("/path/to/action.yml")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unknown files", () => {
|
||||
expect(isWorkflowDocument("/path/to/config.yml")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Document type detection for workflow and action files.
|
||||
* Detection is based on file path/name only - content heuristics are not used
|
||||
* because files in non-standard locations wouldn't work as workflows/actions anyway.
|
||||
*/
|
||||
|
||||
export type DocumentType = "workflow" | "action" | "unknown";
|
||||
|
||||
/**
|
||||
* Detects whether a document is a workflow file, action file, or unknown based on its URI.
|
||||
*
|
||||
* @param uri The document URI or file path
|
||||
* @returns The detected document type
|
||||
*/
|
||||
export function detectDocumentType(uri: string): DocumentType {
|
||||
// Normalize path separators
|
||||
const normalizedUri = uri.replace(/\\/g, "/");
|
||||
|
||||
// Check for workflow file patterns FIRST (more specific path takes precedence)
|
||||
// Matches: .github/workflows/*.yml or .github/workflows/*.yaml
|
||||
// Also matches: .github/workflows-lab/*.yml or .github/workflows-lab/*.yaml
|
||||
// This ensures .github/workflows/action.yml is treated as a workflow, not an action
|
||||
if (/\.github\/workflows(-lab)?\/[^/]+\.ya?ml$/i.test(normalizedUri)) {
|
||||
return "workflow";
|
||||
}
|
||||
|
||||
// Check for action.yml/action.yaml patterns
|
||||
// Matches: action.yml, action.yaml, .github/actions/my-action/action.yml, etc.
|
||||
if (/\/action\.ya?ml$/i.test(normalizedUri) || /^action\.ya?ml$/i.test(normalizedUri)) {
|
||||
return "action";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a document is an action file
|
||||
*/
|
||||
export function isActionDocument(uri: string): boolean {
|
||||
return detectDocumentType(uri) === "action";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a document is a workflow file
|
||||
*/
|
||||
export function isWorkflowDocument(uri: string): boolean {
|
||||
return detectDocumentType(uri) === "workflow";
|
||||
}
|
||||
@@ -6,8 +6,24 @@ import {Range} from "vscode-languageserver-types";
|
||||
|
||||
const PLACEHOLDER_KEY = "key";
|
||||
|
||||
// Transform a document to work around YAML parsing issues
|
||||
// Based on `_transform` in https://github.com/cschleiden/github-actions-parser/blob/main/src/lib/parser/complete.ts#L311
|
||||
/**
|
||||
* Transforms a document to make it valid YAML so the parser can understand
|
||||
* the cursor position during auto-completion.
|
||||
*
|
||||
* When typing in an IDE, the document is usually invalid YAML:
|
||||
* - `runs-on` without `:` isn't a valid key
|
||||
* - Empty lines don't parse as anything
|
||||
* - `- ` without a value isn't complete
|
||||
*
|
||||
* This function inserts placeholders to make the document parseable:
|
||||
* - Empty line → inserts `key:` placeholder
|
||||
* - Line without colon → appends `:`
|
||||
* - Sequence item `- ` → inserts `key` after the dash
|
||||
*
|
||||
* Lines containing `${{` are skipped to avoid breaking multi-line strings.
|
||||
*
|
||||
* The `isPlaceholder()` helper filters out the fake entries from completions.
|
||||
*/
|
||||
export function transform(doc: TextDocument, pos: Position): [TextDocument, Position] {
|
||||
let offset = doc.offsetAt(pos);
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import {convertWorkflowTemplate, parseWorkflow, ParseWorkflowResult, WorkflowTemplate} from "@actions/workflow-parser";
|
||||
import {convertWorkflowTemplate, parseWorkflow, TemplateParseResult, WorkflowTemplate} from "@actions/workflow-parser";
|
||||
import {parseAction} from "@actions/workflow-parser/actions/action-parser";
|
||||
import {
|
||||
ActionTemplate,
|
||||
ActionTemplateConverterOptions,
|
||||
convertActionTemplate
|
||||
} from "@actions/workflow-parser/actions/action-template";
|
||||
import {WorkflowTemplateConverterOptions} from "@actions/workflow-parser/model/convert";
|
||||
import {TemplateContext} from "@actions/workflow-parser/templates/template-context";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
@@ -7,28 +13,36 @@ import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {CompletionConfig} from "../complete.js";
|
||||
import {nullTrace} from "../nulltrace.js";
|
||||
|
||||
const parsedWorkflowCache = new Map<string, ParseWorkflowResult>();
|
||||
const parsedWorkflowCache = new Map<string, TemplateParseResult>();
|
||||
const parsedActionCache = new Map<string, TemplateParseResult>();
|
||||
const workflowTemplateCache = new Map<string, WorkflowTemplate>();
|
||||
const actionTemplateCache = new Map<string, ActionTemplate>();
|
||||
|
||||
export function clearCacheEntry(uri: string) {
|
||||
parsedWorkflowCache.delete(uri);
|
||||
parsedWorkflowCache.delete(workflowKey(uri, true));
|
||||
parsedWorkflowCache.delete(cacheKey(uri, true));
|
||||
parsedActionCache.delete(uri);
|
||||
parsedActionCache.delete(cacheKey(uri, true));
|
||||
workflowTemplateCache.delete(uri);
|
||||
workflowTemplateCache.delete(workflowKey(uri, true));
|
||||
workflowTemplateCache.delete(cacheKey(uri, true));
|
||||
actionTemplateCache.delete(uri);
|
||||
actionTemplateCache.delete(cacheKey(uri, true));
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
parsedWorkflowCache.clear();
|
||||
parsedActionCache.clear();
|
||||
workflowTemplateCache.clear();
|
||||
actionTemplateCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a workflow file and caches the result
|
||||
* Parses a workflow file, returning cached result if available
|
||||
* @param transformed Indicates whether the workflow has been transformed before parsing
|
||||
* @returns the {@link ParseWorkflowResult}
|
||||
* @returns the {@link TemplateParseResult}
|
||||
*/
|
||||
export function fetchOrParseWorkflow(file: File, uri: string, transformed = false): ParseWorkflowResult {
|
||||
const key = workflowKey(uri, transformed);
|
||||
export function getOrParseWorkflow(file: File, uri: string, transformed = false): TemplateParseResult {
|
||||
const key = cacheKey(uri, transformed);
|
||||
const cachedResult = parsedWorkflowCache.get(key);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
@@ -39,11 +53,27 @@ export function fetchOrParseWorkflow(file: File, uri: string, transformed = fals
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a workflow template and caches the result
|
||||
* Parses an action file, returning cached result if available
|
||||
* @param transformed Indicates whether the action has been transformed before parsing
|
||||
* @returns the {@link TemplateParseResult}
|
||||
*/
|
||||
export function getOrParseAction(file: File, uri: string, transformed = false): TemplateParseResult {
|
||||
const key = cacheKey(uri, transformed);
|
||||
const cachedResult = parsedActionCache.get(key);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
const result = parseAction(file, nullTrace);
|
||||
parsedActionCache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a workflow template, returning cached result if available
|
||||
* @param transformed Indicates whether the workflow has been transformed before parsing
|
||||
* @returns the converted {@link WorkflowTemplate}
|
||||
*/
|
||||
export async function fetchOrConvertWorkflowTemplate(
|
||||
export async function getOrConvertWorkflowTemplate(
|
||||
context: TemplateContext,
|
||||
template: TemplateToken,
|
||||
uri: string,
|
||||
@@ -51,7 +81,7 @@ export async function fetchOrConvertWorkflowTemplate(
|
||||
options?: WorkflowTemplateConverterOptions,
|
||||
transformed = false
|
||||
): Promise<WorkflowTemplate> {
|
||||
const key = workflowKey(uri, transformed);
|
||||
const key = cacheKey(uri, transformed);
|
||||
const cachedTemplate = workflowTemplateCache.get(key);
|
||||
if (cachedTemplate) {
|
||||
return cachedTemplate;
|
||||
@@ -61,8 +91,30 @@ export async function fetchOrConvertWorkflowTemplate(
|
||||
return workflowTemplate;
|
||||
}
|
||||
|
||||
// Use a separate cache key for transformed workflows
|
||||
function workflowKey(uri: string, transformed: boolean): string {
|
||||
/**
|
||||
* Converts an action template, returning cached result if available
|
||||
* @param transformed Indicates whether the action has been transformed before parsing
|
||||
* @returns the converted {@link ActionTemplate}
|
||||
*/
|
||||
export function getOrConvertActionTemplate(
|
||||
context: TemplateContext,
|
||||
template: TemplateToken,
|
||||
uri: string,
|
||||
options?: ActionTemplateConverterOptions,
|
||||
transformed = false
|
||||
): ActionTemplate {
|
||||
const key = cacheKey(uri, transformed);
|
||||
const cachedTemplate = actionTemplateCache.get(key);
|
||||
if (cachedTemplate) {
|
||||
return cachedTemplate;
|
||||
}
|
||||
const actionTemplate = convertActionTemplate(context, template, options);
|
||||
actionTemplateCache.set(key, actionTemplate);
|
||||
return actionTemplate;
|
||||
}
|
||||
|
||||
// Use a separate cache key for transformed documents
|
||||
function cacheKey(uri: string, transformed: boolean): string {
|
||||
if (transformed) {
|
||||
return `transformed-${uri}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {parseActionReference} from "./action.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {ValidationConfig} from "./validate.js";
|
||||
|
||||
/**
|
||||
* Validates action references in workflow steps, checking for valid inputs and required inputs.
|
||||
*/
|
||||
export async function validateActionReference(
|
||||
diagnostics: Diagnostic[],
|
||||
stepToken: TemplateToken,
|
||||
step: Step | undefined,
|
||||
config: ValidationConfig | undefined
|
||||
): Promise<void> {
|
||||
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.actionsMetadataProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the action reference (e.g., "actions/checkout@v4" -> {owner, name, ref})
|
||||
const action = parseActionReference(step.uses.value);
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the action's metadata (action.yml) to get input definitions
|
||||
const actionMetadata = await config.actionsMetadataProvider.fetchActionMetadata(action);
|
||||
if (actionMetadata === undefined) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(step.uses.range),
|
||||
message: `Unable to resolve action \`${step.uses.value}\`, repository or version not found`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the "with" key in the step token to get the inputs passed to the action
|
||||
let withKey: ScalarToken | undefined;
|
||||
let withToken: TemplateToken | undefined;
|
||||
for (const {key, value} of stepToken) {
|
||||
if (key.toString() === "with") {
|
||||
withKey = key;
|
||||
withToken = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect the inputs provided in the step's "with" block
|
||||
const stepInputs = new Map<string, ScalarToken>();
|
||||
if (withToken && isMapping(withToken)) {
|
||||
for (const {key} of withToken) {
|
||||
stepInputs.set(key.toString(), key);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip validation if the action doesn't define any inputs
|
||||
const actionInputs = actionMetadata.inputs;
|
||||
if (actionInputs === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check each provided input is valid and not deprecated
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for required inputs that weren't provided and don't have defaults
|
||||
const missingRequiredInputs = Object.entries(actionInputs).filter(
|
||||
([inputName, input]) => input.required && !stepInputs.has(inputName) && input.default === undefined
|
||||
);
|
||||
|
||||
// Report missing required inputs
|
||||
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,350 @@
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {validate} from "./validate";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
|
||||
describe("validate action files", () => {
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
function createActionDocument(content: string, uri = "file:///test/action.yml"): TextDocument {
|
||||
return TextDocument.create(uri, "yaml", 1, content);
|
||||
}
|
||||
|
||||
describe("valid action files", () => {
|
||||
it("validates a minimal composite action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Does something
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "Hello"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates a node20 action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: A JavaScript action
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates a docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: A Docker action
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates an action with inputs and outputs", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Action with I/O
|
||||
inputs:
|
||||
name:
|
||||
description: The name to greet
|
||||
required: true
|
||||
greeting:
|
||||
description: The greeting
|
||||
default: Hello
|
||||
outputs:
|
||||
result:
|
||||
description: The greeting result
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "$\{{ inputs.greeting }} $\{{ inputs.name }}"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates an action with branding", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Branded action
|
||||
branding:
|
||||
icon: activity
|
||||
color: blue
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid action files", () => {
|
||||
it("reports error for missing required name", async () => {
|
||||
const doc = createActionDocument(`
|
||||
description: An action without a name
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "Hi"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("name");
|
||||
});
|
||||
|
||||
it("reports error for missing required description", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "Hi"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("description");
|
||||
});
|
||||
|
||||
it("reports error for missing runs", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: An action without runs
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("runs");
|
||||
});
|
||||
|
||||
it("reports error for missing using in runs", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Missing using
|
||||
runs:
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("using");
|
||||
});
|
||||
|
||||
it("reports error for invalid branding icon", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Bad icon
|
||||
branding:
|
||||
icon: not-a-real-icon
|
||||
color: blue
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("not-a-real-icon");
|
||||
});
|
||||
|
||||
it("reports error for invalid branding color", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Bad color
|
||||
branding:
|
||||
icon: activity
|
||||
color: pink
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("pink");
|
||||
});
|
||||
|
||||
it("reports error for composite step missing shell", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Missing shell
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "Hi"
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("shell");
|
||||
});
|
||||
|
||||
it("reports error for invalid YAML syntax", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Bad YAML
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: |
|
||||
echo "Bad indentation"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("document type routing", () => {
|
||||
it("routes action.yml to action validation", async () => {
|
||||
const doc = createActionDocument(
|
||||
`
|
||||
name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`,
|
||||
"file:///my-repo/action.yml"
|
||||
);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes action.yaml to action validation", async () => {
|
||||
const doc = createActionDocument(
|
||||
`
|
||||
name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`,
|
||||
"file:///my-repo/action.yaml"
|
||||
);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes nested action.yml to action validation", async () => {
|
||||
const doc = createActionDocument(
|
||||
`
|
||||
name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo test
|
||||
shell: bash
|
||||
`,
|
||||
"file:///my-repo/.github/actions/my-action/action.yml"
|
||||
);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composite action step validation", () => {
|
||||
it("validates action inputs in composite action uses steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Composite Action
|
||||
description: A composite action with uses steps
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
invalid-input: value
|
||||
`);
|
||||
const mockMetadataProvider = {
|
||||
fetchActionMetadata: () =>
|
||||
Promise.resolve({
|
||||
name: "Checkout",
|
||||
description: "Checkout a repo",
|
||||
inputs: {
|
||||
repository: {description: "Repository name", required: false},
|
||||
ref: {description: "Branch or tag", required: false}
|
||||
}
|
||||
})
|
||||
};
|
||||
const diagnostics = await validate(doc, {actionsMetadataProvider: mockMetadataProvider});
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("invalid-input");
|
||||
});
|
||||
|
||||
it("validates required inputs in composite action uses steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Composite Action
|
||||
description: A composite action with uses steps
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/some-action@v1
|
||||
`);
|
||||
const mockMetadataProvider = {
|
||||
fetchActionMetadata: () =>
|
||||
Promise.resolve({
|
||||
name: "Some Action",
|
||||
description: "An action with required inputs",
|
||||
inputs: {
|
||||
"required-input": {description: "A required input", required: true}
|
||||
}
|
||||
})
|
||||
};
|
||||
const diagnostics = await validate(doc, {actionsMetadataProvider: mockMetadataProvider});
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("required-input");
|
||||
});
|
||||
|
||||
it("reports unresolved action in composite action uses steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Composite Action
|
||||
description: A composite action with uses steps
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/nonexistent@v1
|
||||
`);
|
||||
const mockMetadataProvider = {
|
||||
fetchActionMetadata: () => Promise.resolve(undefined)
|
||||
};
|
||||
const diagnostics = await validate(doc, {actionsMetadataProvider: mockMetadataProvider});
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("Unable to resolve action");
|
||||
});
|
||||
|
||||
it("passes validation for valid composite action uses steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Composite Action
|
||||
description: A composite action with uses steps
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: owner/repo
|
||||
`);
|
||||
const mockMetadataProvider = {
|
||||
fetchActionMetadata: () =>
|
||||
Promise.resolve({
|
||||
name: "Checkout",
|
||||
description: "Checkout a repo",
|
||||
inputs: {
|
||||
repository: {description: "Repository name", required: false},
|
||||
ref: {description: "Branch or tag", required: false}
|
||||
}
|
||||
})
|
||||
};
|
||||
const diagnostics = await validate(doc, {actionsMetadataProvider: mockMetadataProvider});
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +1,104 @@
|
||||
/**
|
||||
* Validation for action.yml / action.yaml manifest files
|
||||
*/
|
||||
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {parseActionReference} from "./action.js";
|
||||
import {error} from "./log.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
|
||||
import {validateActionReference} from "./validate-action-reference.js";
|
||||
import {ValidationConfig} from "./validate.js";
|
||||
|
||||
export async function validateAction(
|
||||
diagnostics: Diagnostic[],
|
||||
stepToken: TemplateToken,
|
||||
step: Step | undefined,
|
||||
config: ValidationConfig | undefined
|
||||
): Promise<void> {
|
||||
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.actionsMetadataProvider) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Validates an action.yml file
|
||||
*
|
||||
* @param textDocument Document to validate
|
||||
* @param config Optional validation configuration for action metadata provider
|
||||
* @returns Array of diagnostics
|
||||
*/
|
||||
export async function validateAction(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
|
||||
const file: File = {
|
||||
name: textDocument.uri,
|
||||
content: textDocument.getText()
|
||||
};
|
||||
|
||||
const action = parseActionReference(step.uses.value);
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
const actionMetadata = await config.actionsMetadataProvider.fetchActionMetadata(action);
|
||||
if (actionMetadata === undefined) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(step.uses.range),
|
||||
message: `Unable to resolve action \`${step.uses.value}\`, repository or version not found`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let withKey: ScalarToken | undefined;
|
||||
let withToken: TemplateToken | undefined;
|
||||
for (const {key, value} of stepToken) {
|
||||
if (key.toString() === "with") {
|
||||
withKey = key;
|
||||
withToken = value;
|
||||
break;
|
||||
try {
|
||||
// Parse and validate the action.yml against the schema
|
||||
const result = getOrParseAction(file, textDocument.uri);
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const stepInputs = new Map<string, ScalarToken>();
|
||||
if (withToken && isMapping(withToken)) {
|
||||
for (const {key} of withToken) {
|
||||
stepInputs.set(key.toString(), key);
|
||||
}
|
||||
}
|
||||
// Map parser errors to diagnostics
|
||||
for (const err of result.context.errors.getErrors()) {
|
||||
const range = mapRange(err.range);
|
||||
|
||||
const actionInputs = actionMetadata.inputs;
|
||||
if (actionInputs === undefined) {
|
||||
return;
|
||||
}
|
||||
// Determine severity based on error type
|
||||
let severity: DiagnosticSeverity = DiagnosticSeverity.Error;
|
||||
|
||||
// Treat deprecation warnings as warnings
|
||||
if (err.rawMessage.includes("deprecated")) {
|
||||
severity = DiagnosticSeverity.Warning;
|
||||
}
|
||||
|
||||
for (const [input, inputToken] of stepInputs) {
|
||||
if (!actionInputs[input]) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(inputToken.range),
|
||||
message: `Invalid action input '${input}'`
|
||||
message: err.rawMessage,
|
||||
range,
|
||||
severity
|
||||
});
|
||||
}
|
||||
|
||||
const deprecationMessage = actionInputs[input]?.deprecationMessage;
|
||||
if (deprecationMessage) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: mapRange(inputToken.range),
|
||||
message: deprecationMessage
|
||||
// Validate composite action steps if we have a parsed result
|
||||
if (result.value) {
|
||||
const template = getOrConvertActionTemplate(result.context, result.value, textDocument.uri, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
// Only composite actions have steps to validate
|
||||
if (template?.runs?.using === "composite") {
|
||||
const steps = template.runs.steps ?? [];
|
||||
|
||||
// Find the steps sequence token from the raw parsed result
|
||||
const stepsSequence = findStepsSequence(result.value);
|
||||
if (stepsSequence) {
|
||||
// Validate each action step
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const stepToken = stepsSequence.get(i);
|
||||
|
||||
// Validate action references (inputs, required fields) for uses steps
|
||||
if (isActionStep(step) && isMapping(stepToken)) {
|
||||
await validateActionReference(diagnostics, stepToken, step, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error(`Unhandled error while validating action file: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
const missingRequiredInputs = Object.entries(actionInputs).filter(
|
||||
([inputName, input]) => input.required && !stepInputs.has(inputName) && input.default === undefined
|
||||
);
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
/**
|
||||
* Find the steps sequence token from the raw action template.
|
||||
* Traverses the token tree looking for the "composite-steps" definition.
|
||||
*/
|
||||
function findStepsSequence(root: TemplateToken): SequenceToken | undefined {
|
||||
for (const [, token] of TemplateToken.traverse(root)) {
|
||||
if (token.definition?.key === "composite-steps" && token instanceof SequenceToken) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Lexer, Parser, data} from "@actions/expressions";
|
||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
||||
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||
@@ -15,15 +15,17 @@ import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types";
|
||||
import {ActionMetadata, ActionReference} from "./action.js";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
import {Mode, getContext} from "./context-providers/default.js";
|
||||
import {Mode, getWorkflowExpressionContext} from "./context-providers/default.js";
|
||||
import {WorkflowContext, getWorkflowContext} from "./context/workflow-context.js";
|
||||
import {wrapDictionary} from "./expression-validation/error-dictionary.js";
|
||||
import {ValidationEvaluator} from "./expression-validation/evaluator.js";
|
||||
import {validatorFunctions} from "./expression-validation/functions.js";
|
||||
import {error} from "./log.js";
|
||||
import {isActionDocument} from "./utils/document-type.js";
|
||||
import {findToken} from "./utils/find-token.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {validateActionReference} from "./validate-action-reference.js";
|
||||
import {validateAction} from "./validate-action.js";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||
import {defaultValueProviders} from "./value-providers/default.js";
|
||||
@@ -43,12 +45,24 @@ export type ActionsMetadataProvider = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a workflow file
|
||||
* Validates a workflow or action file
|
||||
*
|
||||
* @param textDocument Document to validate
|
||||
* @returns Array of diagnostics
|
||||
*/
|
||||
export async function validate(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
|
||||
return isActionDocument(textDocument.uri)
|
||||
? validateAction(textDocument, config)
|
||||
: validateWorkflow(textDocument, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a workflow file
|
||||
*
|
||||
* @param textDocument Document to validate
|
||||
* @returns Array of diagnostics
|
||||
*/
|
||||
async function validateWorkflow(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
|
||||
const file: File = {
|
||||
name: textDocument.uri,
|
||||
content: textDocument.getText()
|
||||
@@ -57,14 +71,14 @@ export async function validate(textDocument: TextDocument, config?: ValidationCo
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
try {
|
||||
const result: ParseWorkflowResult | undefined = fetchOrParseWorkflow(file, textDocument.uri);
|
||||
const result: TemplateParseResult | undefined = getOrParseWorkflow(file, textDocument.uri);
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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 = await fetchOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
|
||||
const template = await getOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
@@ -155,7 +169,7 @@ async function additionalValidations(
|
||||
// Validate action metadata (inputs, required fields) for regular steps
|
||||
if (token.definition?.key === "regular-step" && token.range) {
|
||||
const context = getProviderContext(documentUri, template, root, token.range);
|
||||
await validateAction(diagnostics, token, context.step, config);
|
||||
await validateActionReference(diagnostics, token, context.step, config);
|
||||
}
|
||||
|
||||
// Validate job-level reusable workflow uses field format
|
||||
@@ -180,7 +194,7 @@ async function additionalValidations(
|
||||
if (token.range && validationDefinition) {
|
||||
const defKey = validationDefinition.key;
|
||||
if (defKey === "step-with") {
|
||||
// Action inputs should be validated already in validateAction
|
||||
// Action inputs should be validated already in validateActionReference
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -721,7 +735,12 @@ async function validateExpression(
|
||||
continue;
|
||||
}
|
||||
|
||||
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
|
||||
const context = await getWorkflowExpressionContext(
|
||||
namedContexts,
|
||||
contextProviderConfig,
|
||||
workflowContext,
|
||||
Mode.Validation
|
||||
);
|
||||
|
||||
const e = new ValidationEvaluator(expr, wrapDictionary(context), validatorFunctions);
|
||||
e.validate();
|
||||
|
||||
@@ -6,7 +6,7 @@ import {MappingDefinition} from "@actions/workflow-parser/templates/schema/mappi
|
||||
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
||||
import {SequenceDefinition} from "@actions/workflow-parser/templates/schema/sequence-definition";
|
||||
import {StringDefinition} from "@actions/workflow-parser/templates/schema/string-definition";
|
||||
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
|
||||
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
|
||||
import {Value} from "./config.js";
|
||||
import {stringsToValues} from "./strings-to-values.js";
|
||||
|
||||
@@ -47,21 +47,21 @@ export type TokenStructure = "scalar" | "sequence" | "mapping" | undefined;
|
||||
* @param tokenStructure - If provided, filters completions to only those matching
|
||||
* the YAML structure the user has already started (e.g., only mapping keys if
|
||||
* they've started a mapping)
|
||||
* @param schema - The schema to use for definition lookups
|
||||
*/
|
||||
export function definitionValues(
|
||||
def: Definition,
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode,
|
||||
tokenStructure?: TokenStructure
|
||||
tokenStructure: TokenStructure | undefined,
|
||||
schema: TemplateSchema
|
||||
): Value[] {
|
||||
const schema = getWorkflowSchema();
|
||||
|
||||
if (def instanceof MappingDefinition) {
|
||||
return mappingValues(def, schema.definitions, indentation, mode);
|
||||
}
|
||||
|
||||
if (def instanceof OneOfDefinition) {
|
||||
return oneOfValues(def, schema.definitions, indentation, mode, tokenStructure);
|
||||
return oneOfValues(def, schema.definitions, indentation, mode, tokenStructure, schema);
|
||||
}
|
||||
|
||||
if (def instanceof BooleanDefinition) {
|
||||
@@ -80,7 +80,7 @@ export function definitionValues(
|
||||
if (def instanceof SequenceDefinition) {
|
||||
const itemDef = schema.getDefinition(def.itemType);
|
||||
if (itemDef) {
|
||||
return definitionValues(itemDef, indentation, mode);
|
||||
return definitionValues(itemDef, indentation, mode, undefined, schema);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,8 @@ function oneOfValues(
|
||||
definitions: {[key: string]: Definition},
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode,
|
||||
tokenStructure?: TokenStructure
|
||||
tokenStructure: TokenStructure | undefined,
|
||||
schema: TemplateSchema
|
||||
): Value[] {
|
||||
const values: Value[] = [];
|
||||
for (const key of oneOfDefinition.oneOf) {
|
||||
@@ -209,7 +210,7 @@ function oneOfValues(
|
||||
}
|
||||
}
|
||||
|
||||
values.push(...definitionValues(variantDef, indentation, mode, tokenStructure));
|
||||
values.push(...definitionValues(variantDef, indentation, mode, tokenStructure, schema));
|
||||
}
|
||||
return distinctValues(values);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user