Compare commits

..
Author SHA1 Message Date
eric sciple 9f260c4658 Add format string validation in expressions package
Format string validation now happens at parse time in the Parser:
- Invalid format string syntax throws ErrorInvalidFormatString
- Argument count mismatch throws ErrorFormatArgCountMismatch

This catches errors early and automatically for all consumers.
2026-01-07 22:00:04 +00:00
eric sciple dbf7752734 Show cron description on hover (#291)
Related #286 - When hovering over a cron expression, show the human-readable
description instead of empty content. Users who have inlay hints disabled
can now still see the cron description.
2026-01-07 08:43:22 -06:00
eric sciple 78231482f5 Fix completion and validation issues in action.yml (#290)
Follow-up to https://github.com/actions/languageservices/pull/289

## What this fixes

**Autocomplete was broken inside composite action steps.** When you typed inside a step and triggered autocomplete, nothing showed up. Now you correctly get suggestions like run, uses, shell, etc.

**Duplicate error messages for missing required fields.** When a required field was missing (like main for Node.js actions), users saw two error messages - one generic schema validation error, and one custom error with a clear explanation. Now they only see the custom one.

For example, with using: node24 but no main:
- Before: Two errors shown
  - Schema: "There's not enough info to determine what you meant. Add one of these properties: args, entrypoint, image, main, ..."
  - Custom: "'main' is required for Node.js actions (using: node24)"
- After: Only the custom error is shown
2026-01-07 08:42:59 -06:00
eric sciple 2e46c66878 Context-aware autocomplete and validation for action.yml runs section (#289)
- Set main as required in node-runs-strict schema definition
- Add validation for invalid key combinations based on using value
- Add validation for missing required keys (main for node, steps for composite, image for docker)
- Filter autocomplete suggestions based on using value
- Prioritize 'using' in completions when not set yet

Fixes context-aware autocomplete for action.yml files where different
action types (node, composite, docker) have different valid keys under runs:
2026-01-06 21:09:38 -06:00
Francesco Renzi 39b9b14e3a Add experimentalFeatures to initialization options (#287)
* Add experimentalFeatures to initialization options

Introduce a feature flagging system for opt-in experimental features.
Clients can enable features via initializationOptions.experimentalFeatures
with granular per-feature control or an 'all' flag to enable everything.

First experimental feature: missingInputsQuickfix (for upcoming code actions)
2026-01-06 07:03:51 +00:00
19 changed files with 1135 additions and 43 deletions
+9 -3
View File
@@ -13,12 +13,14 @@ export enum ErrorType {
ErrorTooFewParameters,
ErrorTooManyParameters,
ErrorUnrecognizedContext,
ErrorUnrecognizedFunction
ErrorUnrecognizedFunction,
ErrorInvalidFormatString,
ErrorFormatArgCountMismatch
}
export class ExpressionError extends Error {
constructor(private typ: ErrorType, private tok: Token) {
super(`${errorDescription(typ)}: '${tokenString(tok)}'`);
constructor(private typ: ErrorType, private tok: Token, customMessage?: string) {
super(customMessage ?? `${errorDescription(typ)}: '${tokenString(tok)}'`);
this.pos = this.tok.range.start;
}
@@ -46,6 +48,10 @@ function errorDescription(typ: ErrorType): string {
return "Unrecognized named-value";
case ErrorType.ErrorUnrecognizedFunction:
return "Unrecognized function";
case ErrorType.ErrorInvalidFormatString:
return "Invalid format string";
case ErrorType.ErrorFormatArgCountMismatch:
return "Format string argument count mismatch";
default: // Should never reach here.
return "Unknown error";
}
+57
View File
@@ -0,0 +1,57 @@
import {FeatureFlags} from "./features.js";
describe("FeatureFlags", () => {
describe("isEnabled", () => {
it("returns false by default when no options provided", () => {
const flags = new FeatureFlags();
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns false by default when empty options provided", () => {
const flags = new FeatureFlags({});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns true when feature is explicitly enabled", () => {
const flags = new FeatureFlags({missingInputsQuickfix: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
it("returns false when feature is explicitly disabled", () => {
const flags = new FeatureFlags({missingInputsQuickfix: false});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns true when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
it("explicit feature flag takes precedence over all:true", () => {
const flags = new FeatureFlags({all: true, missingInputsQuickfix: false});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("explicit feature flag takes precedence over all:false", () => {
const flags = new FeatureFlags({all: false, missingInputsQuickfix: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
});
describe("getEnabledFeatures", () => {
it("returns empty array when no features enabled", () => {
const flags = new FeatureFlags();
expect(flags.getEnabledFeatures()).toEqual([]);
});
it("returns enabled features", () => {
const flags = new FeatureFlags({missingInputsQuickfix: true});
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
});
it("returns all features when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
});
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Experimental feature flags.
*
* Individual feature flags take precedence over `all`.
* Example: { all: true, missingInputsQuickfix: false } enables all
* experimental features EXCEPT missingInputsQuickfix.
*
* When a feature graduates to stable, its flag becomes a no-op
* (the feature will be enabled regardless of the configuration value).
*/
export interface ExperimentalFeatures {
/**
* Enable all experimental features.
* Individual feature flags take precedence over this setting.
* @default false
*/
all?: boolean;
/**
* Enable quickfix code action for missing required action inputs.
* @default false
*/
missingInputsQuickfix?: boolean;
}
/**
* Keys of ExperimentalFeatures that represent actual features (excludes 'all')
*/
export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
/**
* All known experimental feature keys.
* This list must be kept in sync with the ExperimentalFeatures interface.
*/
const allFeatureKeys: ExperimentalFeatureKey[] = ["missingInputsQuickfix"];
export class FeatureFlags {
private readonly features: ExperimentalFeatures;
constructor(features?: ExperimentalFeatures) {
this.features = features ?? {};
}
/**
* Check if an experimental feature is enabled.
*
* Resolution order:
* 1. Explicit feature flag (if set)
* 2. `all` flag (if set)
* 3. false (default)
*/
isEnabled(feature: ExperimentalFeatureKey): boolean {
const explicit = this.features[feature];
if (explicit !== undefined) {
return explicit;
}
return this.features.all ?? false;
}
/**
* Returns list of all enabled experimental features.
*/
getEnabledFeatures(): ExperimentalFeatureKey[] {
return allFeatureKeys.filter(key => this.isEnabled(key));
}
}
+3 -1
View File
@@ -2,8 +2,10 @@ export {Expr} from "./ast.js";
export {complete, CompletionItem} from "./completion.js";
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js";
export * as data from "./data/index.js";
export {ExpressionError, ExpressionEvaluationError} from "./errors.js";
export {ErrorType, ExpressionError, ExpressionEvaluationError} from "./errors.js";
export {Evaluator} from "./evaluator.js";
export {ExperimentalFeatureKey, ExperimentalFeatures, FeatureFlags} from "./features.js";
export {wellKnownFunctions} from "./funcs.js";
export {Lexer, Result} from "./lexer.js";
export {Parser} from "./parser.js";
export {validateFormatString} from "./validate-format.js";
+25
View File
@@ -15,6 +15,7 @@ import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors.js";
import {ParseContext, validateFunction} from "./funcs.js";
import {FunctionInfo} from "./funcs/info.js";
import {Token, TokenType} from "./lexer.js";
import {validateFormatString} from "./validate-format.js";
export class Parser {
private extContexts: Map<string, boolean>;
@@ -261,6 +262,30 @@ export class Parser {
validateFunction(this.context, identifier, args.length);
// Validate format() calls
if (identifier.lexeme.toLowerCase() === "format" && args.length > 0) {
const firstArg = args[0];
if (firstArg instanceof Literal && firstArg.literal.kind === data.Kind.String) {
const formatString = firstArg.literal.coerceString();
const result = validateFormatString(formatString);
if (!result.valid) {
throw new ExpressionError(ErrorType.ErrorInvalidFormatString, identifier);
}
// Check argument count: format string uses {0} to {N}, so need N+1 args after format string
const providedArgs = args.length - 1;
const requiredArgs = result.maxArgIndex + 1;
if (requiredArgs > providedArgs) {
throw new ExpressionError(
ErrorType.ErrorFormatArgCountMismatch,
identifier,
`Format string references {${result.maxArgIndex}} but only ${providedArgs} argument(s) provided`
);
}
}
}
return new FunctionCall(identifier, args);
}
+63
View File
@@ -0,0 +1,63 @@
import {validateFormatString} from "./validate-format.js";
describe("validateFormatString", () => {
it("returns valid for simple placeholder", () => {
const result = validateFormatString("{0}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
it("returns valid for multiple placeholders", () => {
const result = validateFormatString("{0} {1} {2}");
expect(result).toEqual({valid: true, maxArgIndex: 2});
});
it("returns valid for text with placeholder", () => {
const result = validateFormatString("hello {0} world");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
it("returns valid for escaped left braces", () => {
const result = validateFormatString("{{0}} {0}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
it("returns valid for escaped right braces", () => {
const result = validateFormatString("{0}}}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
it("returns valid for no placeholders", () => {
const result = validateFormatString("hello world");
expect(result).toEqual({valid: true, maxArgIndex: -1});
});
it("returns invalid for missing closing brace", () => {
const result = validateFormatString("{0");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});
it("returns invalid for empty placeholder", () => {
const result = validateFormatString("{}");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});
it("returns invalid for non-numeric placeholder", () => {
const result = validateFormatString("{abc}");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});
it("returns invalid for unescaped closing brace", () => {
const result = validateFormatString("text } more");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});
it("handles out-of-order placeholders", () => {
const result = validateFormatString("{2} {0} {1}");
expect(result).toEqual({valid: true, maxArgIndex: 2});
});
it("handles repeated placeholders", () => {
const result = validateFormatString("{0} {0} {0}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Format string validation for format() function calls.
* Validates format string syntax and argument count at parse time.
*/
/**
* Validates a format string and returns the maximum placeholder index.
*
* @param formatString The format string to validate
* @returns { valid: boolean, maxArgIndex: number } where maxArgIndex is -1 if no placeholders
*/
export function validateFormatString(formatString: string): {valid: boolean; maxArgIndex: number} {
let maxIndex = -1;
let i = 0;
while (i < formatString.length) {
// Find next left brace
let lbrace = -1;
for (let j = i; j < formatString.length; j++) {
if (formatString[j] === "{") {
lbrace = j;
break;
}
}
// Find next right brace
let rbrace = -1;
for (let j = i; j < formatString.length; j++) {
if (formatString[j] === "}") {
rbrace = j;
break;
}
}
// No more braces
if (lbrace < 0 && rbrace < 0) {
break;
}
// Left brace comes first (or only left brace exists)
if (lbrace >= 0 && (rbrace < 0 || lbrace < rbrace)) {
// Check if it's escaped
if (lbrace + 1 < formatString.length && formatString[lbrace + 1] === "{") {
// Escaped left brace
i = lbrace + 2;
continue;
}
// This is a placeholder opening - find the closing brace
rbrace = -1;
for (let j = lbrace + 1; j < formatString.length; j++) {
if (formatString[j] === "}") {
rbrace = j;
break;
}
}
if (rbrace < 0) {
// Missing closing brace
return {valid: false, maxArgIndex: -1};
}
// Validate placeholder content (must be digits only)
if (rbrace === lbrace + 1) {
// Empty placeholder {}
return {valid: false, maxArgIndex: -1};
}
// Parse the index and validate it's all digits
let index = 0;
for (let j = lbrace + 1; j < rbrace; j++) {
const c = formatString[j];
if (c < "0" || c > "9") {
// Non-numeric character
return {valid: false, maxArgIndex: -1};
}
index = index * 10 + (c.charCodeAt(0) - "0".charCodeAt(0));
}
if (index > maxIndex) {
maxIndex = index;
}
i = rbrace + 1;
continue;
}
// Right brace comes first (or only right brace exists)
// Check if it's escaped
if (rbrace + 1 < formatString.length && formatString[rbrace + 1] === "}") {
// Escaped right brace
i = rbrace + 2;
continue;
}
// Unescaped right brace outside of placeholder
return {valid: false, maxArgIndex: -1};
}
return {valid: true, maxArgIndex: maxIndex};
}
+34 -34
View File
@@ -87,120 +87,120 @@
{
"expr": "format('{0')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: {0"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('{0', '')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: {0"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('{0}}', '')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: {0}}"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('{0}}}}', '')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: {0}}}}"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('0}')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: 0}"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('0}', '')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: 0}"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('{{0}')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: {{0}"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('{{0}', '')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: {{0}"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('{{{{0}')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: {{{{0}"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('{{{{0}', '')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: {{{{0}"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('}0{')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: }0{"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('}0{', '')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: }0{"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('}{0}')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: }{0}"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('}{0}', '')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: }{0}"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('{0}{', '')",
"err": {
"kind": "evaluation",
"value": "The following format string is invalid: {0}{"
"kind": "parsing",
"value": "Invalid format string"
}
},
{
"expr": "format('{0}')",
"err": {
"kind": "evaluation",
"value": "The following format string references more arguments than were supplied: {0}"
"kind": "parsing",
"value": "Format string references {0} but only 0 argument(s) provided"
}
},
{
"expr": "format('{0}{1}', 'abc')",
"err": {
"kind": "evaluation",
"value": "The following format string references more arguments than were supplied: {0}{1}"
"kind": "parsing",
"value": "Format string references {1} but only 1 argument(s) provided"
}
}
]
+31
View File
@@ -84,6 +84,11 @@ export interface InitializationOptions {
* Desired log level
*/
logLevel?: LogLevel;
/**
* Experimental features that are opt-in
*/
experimentalFeatures?: ExperimentalFeatures;
}
```
@@ -100,6 +105,32 @@ const clientOptions: LanguageClientOptions = {
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
```
### Experimental Features
The language server supports opt-in experimental features via the `experimentalFeatures` initialization option. These features may change or be removed in between releases.
```typescript
initializationOptions: {
experimentalFeatures: {
// Enable all experimental features
all: true,
// Or enable specific features
missingInputsQuickfix: true,
}
}
```
**Available experimental features:**
| Feature | Description |
|---------|-------------|
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
When a feature graduates to stable, its flag becomes a no-op and the feature will be enabled regardless of the configuration value.
### Standalone CLI
After installing globally, you can run the language server directly:
+9
View File
@@ -24,6 +24,7 @@ import {getClient} from "./client.js";
import {Commands} from "./commands.js";
import {contextProviders} from "./context-providers.js";
import {descriptionProvider} from "./description-provider.js";
import {FeatureFlags} from "@actions/expressions";
import {getFileProvider} from "./file-provider.js";
import {InitializationOptions, RepositoryContext} from "./initializationOptions.js";
import {onCompletion} from "./on-completion.js";
@@ -41,6 +42,7 @@ export function initConnection(connection: Connection) {
const cache = new TTLCache();
let hasWorkspaceFolderCapability = false;
let featureFlags = new FeatureFlags();
// Register remote console logger with language service
registerLogger(connection.console);
@@ -64,6 +66,8 @@ export function initConnection(connection: Connection) {
setLogLevel(options.logLevel);
}
featureFlags = new FeatureFlags(options.experimentalFeatures);
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Full,
@@ -91,6 +95,11 @@ export function initConnection(connection: Connection) {
});
connection.onInitialized(() => {
const enabledFeatures = featureFlags.getEnabledFeatures();
if (enabledFeatures.length > 0) {
connection.console.info(`Experimental features enabled: ${enabledFeatures.join(", ")}`);
}
if (hasWorkspaceFolderCapability) {
connection.workspace.onDidChangeWorkspaceFolders(() => {
clearCache();
@@ -1,3 +1,4 @@
import {ExperimentalFeatures} from "@actions/expressions";
import {LogLevel} from "@actions/languageservice/log";
export {LogLevel} from "@actions/languageservice/log";
@@ -28,6 +29,12 @@ export interface InitializationOptions {
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
*/
gitHubApiUrl?: string;
/**
* Experimental features that are opt-in.
* Features listed here may change or be removed without notice.
*/
experimentalFeatures?: ExperimentalFeatures;
}
export interface RepositoryContext {
+101
View File
@@ -184,6 +184,107 @@ runs:
expect(labels).toContain("using");
});
it("filters runs keys for node20 actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node20
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show Node.js action keys
expect(labels).toContain("main");
expect(labels).toContain("pre");
expect(labels).toContain("post");
expect(labels).toContain("pre-if");
expect(labels).toContain("post-if");
// Should NOT show composite or docker keys
expect(labels).not.toContain("steps");
expect(labels).not.toContain("image");
expect(labels).not.toContain("entrypoint");
});
it("filters runs keys for composite actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: composite
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show composite action keys
expect(labels).toContain("steps");
// Should NOT show Node.js or docker keys
expect(labels).not.toContain("main");
expect(labels).not.toContain("pre");
expect(labels).not.toContain("post");
expect(labels).not.toContain("image");
});
it("filters runs keys for docker actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: docker
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show Docker action keys
expect(labels).toContain("image");
expect(labels).toContain("args");
expect(labels).toContain("env");
expect(labels).toContain("entrypoint");
expect(labels).toContain("pre-entrypoint");
expect(labels).toContain("post-entrypoint");
// Should NOT show Node.js or composite keys
expect(labels).not.toContain("main");
expect(labels).not.toContain("steps");
});
it("prioritizes using when not set", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
|`);
const completions = await complete(doc, position);
// Find the using completion
const usingCompletion = completions.find(c => c.label === "using");
expect(usingCompletion).toBeDefined();
// It should have a sortText that makes it sort first
expect(usingCompletion?.sortText).toBe("0_using");
});
it("completes step keys inside composite action steps", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: composite
steps:
- run: echo hello
shell: bash
- |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show step keys, not filtered by runs-level logic
expect(labels).toContain("run");
expect(labels).toContain("uses");
expect(labels).toContain("shell");
expect(labels).toContain("id");
expect(labels).toContain("name");
expect(labels).toContain("if");
expect(labels).toContain("env");
expect(labels).toContain("working-directory");
});
});
describe("branding completions", () => {
+103 -1
View File
@@ -38,6 +38,24 @@ import {Value, ValueProviderConfig} from "./value-providers/config.js";
import {defaultValueProviders} from "./value-providers/default.js";
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
/**
* Valid keys for each action type under the `runs:` section.
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
*/
const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]);
const ACTION_DOCKER_KEYS = new Set([
"using",
"image",
"args",
"env",
"entrypoint",
"pre-entrypoint",
"pre-if",
"post-entrypoint",
"post-if"
]);
export function getExpressionInput(input: string, pos: number): string {
// Find start marker around the cursor position
let startPos = input.lastIndexOf(OPEN_EXPRESSION, pos);
@@ -137,7 +155,7 @@ export async function complete(
const indentString = " ".repeat(indentation.tabSize);
// YAML key/value completions
const values = await getValues(
let values = await getValues(
token,
keyToken,
parent,
@@ -147,6 +165,11 @@ export async function complete(
schema
);
// Filter action.yml `runs:` completions based on `using:` value
if (isAction && parsedTemplate.value) {
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
}
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
values.push(...escapeHatches);
@@ -603,3 +626,82 @@ function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: P
// = 32 + 11 = 43
return lengthOfContentBeforeCurrentLine + pos.character;
}
/**
* Filters action.yml `runs:` completions based on the `using:` value.
*
* When the user is completing keys under `runs:`:
* - If `using: node20` is set, only show Node.js action keys
* - If `using: composite` is set, only show composite action keys
* - If `using: docker` is set, only show Docker action keys
* - If `using:` is not set, show all keys but prioritize `using` first
*/
function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] {
// Find the runs mapping from the root
let runsMapping: MappingToken | undefined;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const {key, value} = root.get(i);
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
runsMapping = value;
break;
}
}
}
if (!runsMapping) {
return values;
}
// Check if the runs mapping is in our path (meaning we're completing inside it)
const isInsideRuns = path.some(token => token === runsMapping);
if (!isInsideRuns) {
return values;
}
// Find where runsMapping is in the path
const runsMappingIndex = path.indexOf(runsMapping);
if (runsMappingIndex === -1) {
return values;
}
// Check if there's anything after runsMapping in the path
// If so, we're nested deeper (e.g., inside steps sequence or a step mapping)
if (runsMappingIndex < path.length - 1) {
return values;
}
// Get the using value from the runs mapping
let usingValue: string | undefined;
for (let i = 0; i < runsMapping.count; i++) {
const {key, value} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingValue = value.toString();
break;
}
}
// Determine which keys to allow
let allowedKeys: Set<string>;
if (!usingValue) {
// No using value set - show all keys but prioritize "using"
return values.map(v => {
if (v.label.toLowerCase() === "using") {
return {...v, sortText: "0_using"}; // Sort first
}
return v;
});
} else if (usingValue.match(/^node\d+$/i)) {
allowedKeys = ACTION_NODE_KEYS;
} else if (usingValue.toLowerCase() === "composite") {
allowedKeys = ACTION_COMPOSITE_KEYS;
} else if (usingValue.toLowerCase() === "docker") {
allowedKeys = ACTION_DOCKER_KEYS;
} else {
// Unknown using value - show all
return values;
}
// Filter to only allowed keys
return values.filter(v => allowedKeys.has(v.label.toLowerCase()));
}
+1 -2
View File
@@ -110,8 +110,7 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Cron description is now shown via diagnostics, not hover
expect(result?.contents).toEqual("");
expect(result?.contents).toEqual("Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00");
});
it("on a cron mapping key", async () => {
+13
View File
@@ -2,6 +2,8 @@ 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 {isString} from "@actions/workflow-parser";
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
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";
@@ -134,6 +136,17 @@ export async function hover(document: TextDocument, position: Position, config?:
// Non-expression hover: show the schema description for the YAML key or value
info(`Calculating hover for token with definition ${hoverToken.definition.key}`);
// Check for cron expression hover
if (isString(hoverToken) && hoverToken.definition.key === "cron-pattern") {
const cronDescription = getCronDescription(hoverToken.value);
if (cronDescription) {
return {
contents: cronDescription,
range: mapRange(hoverToken.range)
};
}
}
let description: string;
if (!isAction && tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
// Reusable workflow call: fetch the called workflow's input descriptions
+180
View File
@@ -347,4 +347,184 @@ runs:
expect(diagnostics).toEqual([]);
});
});
describe("invalid key combinations based on using type", () => {
it("reports error for node20 action with steps", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - node20 with steps
runs:
using: node20
main: index.js
steps:
- run: echo "hello"
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.length).toBeGreaterThan(0);
// Schema reports "Unexpected value 'steps'" for invalid keys
expect(diagnostics.some(d => d.message.includes("steps"))).toBe(true);
});
it("reports error for composite action with main", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - composite with main
runs:
using: composite
steps:
- run: echo "hello"
shell: bash
main: index.js
`);
const diagnostics = await validate(doc);
expect(diagnostics.length).toBeGreaterThan(0);
// Schema reports "Unexpected value 'main'" for invalid keys
expect(diagnostics.some(d => d.message.includes("main"))).toBe(true);
});
it("reports error for docker action with steps", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - docker with steps
runs:
using: docker
image: Dockerfile
steps:
- run: echo "hello"
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.length).toBeGreaterThan(0);
// Schema reports "Unexpected value 'steps'" for invalid keys
expect(diagnostics.some(d => d.message.includes("steps"))).toBe(true);
});
it("reports error for docker action with main", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - docker with main
runs:
using: docker
image: Dockerfile
main: index.js
`);
const diagnostics = await validate(doc);
expect(diagnostics.length).toBeGreaterThan(0);
// Schema reports "Unexpected value 'main'" for invalid keys
expect(diagnostics.some(d => d.message.includes("main"))).toBe(true);
});
it("reports error for node20 action missing main", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - node20 without main
runs:
using: node20
pre: setup.js
`);
const diagnostics = await validate(doc);
expect(diagnostics.length).toBeGreaterThan(0);
expect(diagnostics.some(d => d.message.includes("main"))).toBe(true);
});
it("reports error for node24 action missing main", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - node24 without main
runs:
using: node24
pre: setup.js
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message === "'main' is required for Node.js actions (using: node24)")).toBe(true);
// Should NOT have duplicate schema error
expect(diagnostics.filter(d => d.message.includes("main")).length).toBe(1);
});
it("reports error for node24 action with only using (no narrowing key)", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - node24 without main
runs:
using: node24
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message === "'main' is required for Node.js actions (using: node24)")).toBe(true);
// Should NOT have the generic "not enough info" schema error
expect(diagnostics.some(d => d.message.includes("There's not enough info"))).toBe(false);
});
it("reports error for composite action missing steps", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - composite without steps
runs:
using: composite
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message === "'steps' is required for composite actions (using: composite)")).toBe(
true
);
// Should NOT have duplicate schema error
expect(diagnostics.some(d => d.message.includes("There's not enough info"))).toBe(false);
});
it("reports error for docker action missing image", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - docker without image
runs:
using: docker
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message === "'image' is required for Docker actions (using: docker)")).toBe(true);
// Should NOT have duplicate schema error
expect(diagnostics.some(d => d.message.includes("There's not enough info"))).toBe(false);
});
it("reports error for docker action with entrypoint but missing image", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - docker without image
runs:
using: docker
entrypoint: /entrypoint.sh
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message === "'image' is required for Docker actions (using: docker)")).toBe(true);
// Should NOT have duplicate "Required property is missing: image" schema error
expect(diagnostics.filter(d => d.message.includes("image")).length).toBe(1);
});
it("lets schema handle missing using", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - no using
runs:
main: index.js
`);
const diagnostics = await validate(doc);
// Should have schema error about not enough info or unexpected value
expect(diagnostics.length).toBeGreaterThan(0);
// Should NOT have custom validation error (can't determine action type)
expect(diagnostics.some(d => d.message.includes("is required for"))).toBe(false);
});
it("lets schema handle invalid using value", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - bad using value
runs:
using: not-supported
main: index.js
`);
const diagnostics = await validate(doc);
// Should have schema error about unexpected value
expect(diagnostics.length).toBeGreaterThan(0);
// Should NOT have custom validation error (unknown action type)
expect(diagnostics.some(d => d.message.includes("is required for"))).toBe(false);
expect(diagnostics.some(d => d.message.includes("is not valid for"))).toBe(false);
});
});
});
+167 -2
View File
@@ -5,8 +5,10 @@
import {isMapping} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
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";
import {TemplateValidationError} from "@actions/workflow-parser/templates/template-validation-error";
import {File} from "@actions/workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
@@ -16,6 +18,31 @@ import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cac
import {validateActionReference} from "./validate-action-reference.js";
import {ValidationConfig} from "./validate.js";
/**
* Valid keys for each action type under the `runs:` section.
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
*/
const NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
const COMPOSITE_KEYS = new Set(["using", "steps"]);
const DOCKER_KEYS = new Set([
"using",
"image",
"args",
"env",
"entrypoint",
"pre-entrypoint",
"pre-if",
"post-entrypoint",
"post-if"
]);
/**
* Required keys for each action type (besides 'using').
*/
const NODE_REQUIRED_KEYS = ["main"];
const COMPOSITE_REQUIRED_KEYS = ["steps"];
const DOCKER_REQUIRED_KEYS = ["image"];
/**
* Validates an action.yml file
*
@@ -38,8 +65,16 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
return [];
}
// Map parser errors to diagnostics
for (const err of result.context.errors.getErrors()) {
// Get schema errors
const schemaErrors = result.context.errors.getErrors();
// Run custom runs key validation, which also filters redundant schema errors in place
if (result.value) {
diagnostics.push(...validateRunsKeysAndFilterErrors(result.value, schemaErrors));
}
// Map remaining schema errors to diagnostics
for (const err of schemaErrors) {
const range = mapRange(err.range);
// Determine severity based on error type
@@ -102,3 +137,133 @@ function findStepsSequence(root: TemplateToken): SequenceToken | undefined {
}
return undefined;
}
/**
* Validates that the keys under `runs:` are valid for the specified `using:` type.
* Also filters out schema errors (in place) that this validation replaces with more specific messages.
*/
function validateRunsKeysAndFilterErrors(
root: TemplateToken,
schemaErrors: TemplateValidationError[] // mutated: redundant errors are removed
): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
// Find the runs mapping from the root
let runsMapping: MappingToken | undefined;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const {key, value} = root.get(i);
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
runsMapping = value;
break;
}
}
}
if (!runsMapping) {
return diagnostics;
}
// Get the using value from the runs mapping
let usingValue: string | undefined;
for (let i = 0; i < runsMapping.count; i++) {
const {key, value} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingValue = value.toString();
break;
}
}
if (!usingValue) {
return diagnostics; // No using value, let schema validation handle it
}
// Determine allowed keys, required keys, and action type name
let allowedKeys: Set<string>;
let requiredKeys: string[];
let actionType: string;
if (usingValue.match(/^node\d+$/i)) {
allowedKeys = NODE_KEYS;
requiredKeys = NODE_REQUIRED_KEYS;
actionType = "Node.js";
} else if (usingValue.toLowerCase() === "composite") {
allowedKeys = COMPOSITE_KEYS;
requiredKeys = COMPOSITE_REQUIRED_KEYS;
actionType = "composite";
} else if (usingValue.toLowerCase() === "docker") {
allowedKeys = DOCKER_KEYS;
requiredKeys = DOCKER_REQUIRED_KEYS;
actionType = "Docker";
} else {
return diagnostics; // Unknown type, let schema validation handle it
}
// Get all present keys
const presentKeys = new Set<string>();
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
presentKeys.add(key.toString().toLowerCase());
}
// Check for invalid keys
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
const keyStr = key.toString().toLowerCase();
if (!allowedKeys.has(keyStr)) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(key.range),
message: `'${key.toString()}' is not valid for ${actionType} actions (using: ${usingValue})`
});
}
}
// Check for missing required keys
for (const requiredKey of requiredKeys) {
if (!presentKeys.has(requiredKey)) {
// Find the 'using' key to report the error location
let usingKeyRange = runsMapping.range;
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingKeyRange = key.range;
break;
}
}
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(usingKeyRange),
message: `'${requiredKey}' is required for ${actionType} actions (using: ${usingValue})`
});
}
}
// Remove schema errors that we're replacing with more specific messages (mutate in place)
for (let i = schemaErrors.length - 1; i >= 0; i--) {
const err = schemaErrors[i];
// Keep errors not at the runs section start
if (
err.range?.start.line !== runsMapping.range?.start.line ||
err.range?.start.column !== runsMapping.range?.start.column
) {
continue;
}
// Check if this is an error we're replacing
const isOneOfAmbiguity = err.rawMessage.startsWith("There's not enough info to determine");
const isRequiredKey = /^Required property is missing: (main|steps|image)$/.test(err.rawMessage);
if (!isOneOfAmbiguity && !isRequiredKey) {
continue; // Keep errors we're not replacing
}
// Remove only if we have custom diagnostics for this
if (diagnostics.length > 0) {
schemaErrors.splice(i, 1);
}
}
return diagnostics;
}
@@ -0,0 +1,164 @@
import {Diagnostic} from "vscode-languageserver-types";
import {createDocument} from "./test-utils/document.js";
import {validate} from "./validate.js";
import {clearCache} from "./utils/workflow-cache.js";
beforeEach(() => {
clearCache();
});
function hasMessageContaining(results: Diagnostic[], substring: string): boolean {
return results.some(r => r.message.includes(substring));
}
describe("format string validation", () => {
describe("InvalidFormatString workflow validation", () => {
it("errors on missing closing brace", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{0', github.event_name) }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(hasMessageContaining(result, "Invalid format string")).toBe(true);
});
it("errors on empty braces", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{}', github.event_name) }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(hasMessageContaining(result, "Invalid format string")).toBe(true);
});
it("errors on non-numeric placeholder", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{abc}', github.event_name) }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(hasMessageContaining(result, "Invalid format string")).toBe(true);
});
it("allows valid format strings", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{0} {1}', github.event_name, github.ref) }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(hasMessageContaining(result, "Invalid format string")).toBe(false);
});
it("allows escaped braces", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{{0}} {0}', github.event_name) }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(hasMessageContaining(result, "Invalid format string")).toBe(false);
});
});
describe("FormatArgCountMismatch workflow validation", () => {
it("errors when placeholder exceeds arg count", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{2}', 'arg0', 'arg1') }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(hasMessageContaining(result, "Format string references {2}")).toBe(true);
});
it("errors when referencing arg 0 with no args", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{0}') }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(hasMessageContaining(result, "Format string references {0}")).toBe(true);
});
it("allows when arg count matches", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{0} {1} {2}', 'a', 'b', 'c') }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(hasMessageContaining(result, "Format string references")).toBe(false);
});
it("handles no placeholders correctly", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('hello world') }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(hasMessageContaining(result, "Format string references")).toBe(false);
});
it("skips validation for dynamic format strings", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format(env.FORMAT_STRING, 'arg') }}
`;
const result = await validate(createDocument("wf.yaml", input));
// Should not have format errors since we can't validate dynamic strings
expect(hasMessageContaining(result, "Invalid format string")).toBe(false);
expect(hasMessageContaining(result, "Format string references")).toBe(false);
});
it("validates nested format calls", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{0}', format('{2}', 'a')) }}
`;
const result = await validate(createDocument("wf.yaml", input));
// The inner format call has an error
expect(hasMessageContaining(result, "Format string references {2}")).toBe(true);
});
});
});
+1
View File
@@ -267,6 +267,7 @@
},
"main": {
"type": "non-empty-string",
"required": true,
"description": "The file that contains your action code. The runtime specified in using executes this file.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsmain)"
},
"pre": {