Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c85997ad0d | |||
| 671f92dbc6 | |||
| fb5c6e4f27 | |||
| f29f508cec | |||
| d69c1fa0f3 | |||
| 191a7b6a00 | |||
| 0410ab8302 | |||
| 7ac83f43a6 | |||
| ef457b29fa | |||
| fea8440c1d | |||
| 3c0a5f79fc | |||
| 448180bd7f | |||
| d2f52a9043 | |||
| 46b216a6dc | |||
| 0fe7798548 | |||
| bdd72406c3 | |||
| 33291f0f8d | |||
| 8511ae2e6d | |||
| cd1078fb2f | |||
| 96be7ce46c | |||
| c2bf928e7b | |||
| 74d69b24ab | |||
| 22aa458809 | |||
| f3f11d8658 | |||
| 5359433879 | |||
| a8bfe74256 | |||
| e2c5f1f74a | |||
| 2a203ec742 | |||
| 92960e0093 | |||
| 0fe31c6656 | |||
| 67dd4fbd61 | |||
| 4a7e08774d | |||
| 9ec1c123a8 | |||
| aad3bcd291 | |||
| 248934d513 | |||
| b605cb6582 | |||
| 05debf64b0 | |||
| 228acc3cd9 | |||
| 9f30846fde | |||
| 0ebe1262ee | |||
| 94d7f7b124 | |||
| f439272f69 | |||
| 161574adac | |||
| 44900feff7 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.35",
|
||||
"version": "0.3.44",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {DescriptionPair} from "./completion/descriptionDictionary.js";
|
||||
import {Dictionary, isDictionary} from "./data/dictionary.js";
|
||||
import {ExpressionData} from "./data/expressiondata.js";
|
||||
import {Evaluator} from "./evaluator.js";
|
||||
import {FeatureFlags} from "./features.js";
|
||||
import {wellKnownFunctions} from "./funcs.js";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||
import {Lexer, Token, TokenType} from "./lexer.js";
|
||||
@@ -26,13 +27,16 @@ export type CompletionItem = {
|
||||
* @param context Context available for the expression
|
||||
* @param extensionFunctions List of functions available
|
||||
* @param functions Optional map of functions to use during evaluation
|
||||
* @param featureFlags Optional feature flags to control which features are enabled
|
||||
* @returns Array of completion items
|
||||
*/
|
||||
export function complete(
|
||||
input: string,
|
||||
context: Dictionary,
|
||||
extensionFunctions: FunctionInfo[],
|
||||
functions?: Map<string, FunctionDefinition>
|
||||
functions?: Map<string, FunctionDefinition>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
featureFlags?: FeatureFlags
|
||||
): CompletionItem[] {
|
||||
// Lex
|
||||
const lexer = new Lexer(input);
|
||||
|
||||
@@ -12,6 +12,7 @@ export enum ErrorType {
|
||||
ErrorExceededMaxLength,
|
||||
ErrorTooFewParameters,
|
||||
ErrorTooManyParameters,
|
||||
ErrorEvenParameters,
|
||||
ErrorUnrecognizedContext,
|
||||
ErrorUnrecognizedFunction
|
||||
}
|
||||
@@ -42,6 +43,8 @@ function errorDescription(typ: ErrorType): string {
|
||||
return "Too few parameters supplied";
|
||||
case ErrorType.ErrorTooManyParameters:
|
||||
return "Too many parameters supplied";
|
||||
case ErrorType.ErrorEvenParameters:
|
||||
return "Even number of parameters supplied, requires an odd number of parameters";
|
||||
case ErrorType.ErrorUnrecognizedContext:
|
||||
return "Unrecognized named-value";
|
||||
case ErrorType.ErrorUnrecognizedFunction:
|
||||
|
||||
@@ -51,11 +51,7 @@ describe("FeatureFlags", () => {
|
||||
|
||||
it("returns all features when all is enabled", () => {
|
||||
const flags = new FeatureFlags({all: true});
|
||||
expect(flags.getEnabledFeatures()).toEqual([
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"actionScaffoldingSnippets"
|
||||
]);
|
||||
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix", "blockScalarChompingWarning"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,11 +30,11 @@ export interface ExperimentalFeatures {
|
||||
blockScalarChompingWarning?: boolean;
|
||||
|
||||
/**
|
||||
* Enable action scaffolding snippets in action.yml files.
|
||||
* Offers Node.js, Composite, and Docker action scaffolds.
|
||||
* Enable improved container image validation that handles
|
||||
* expressions gracefully and validates empty/docker:// images.
|
||||
* @default false
|
||||
*/
|
||||
actionScaffoldingSnippets?: boolean;
|
||||
containerImageValidation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +49,7 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
|
||||
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"actionScaffoldingSnippets"
|
||||
"containerImageValidation"
|
||||
];
|
||||
|
||||
export class FeatureFlags {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {ErrorType, ExpressionError} from "./errors.js";
|
||||
import {caseFunc} from "./funcs/case.js";
|
||||
import {contains} from "./funcs/contains.js";
|
||||
import {endswith} from "./funcs/endswith.js";
|
||||
import {format} from "./funcs/format.js";
|
||||
@@ -16,6 +17,7 @@ export type ParseContext = {
|
||||
};
|
||||
|
||||
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
|
||||
case: caseFunc,
|
||||
contains: contains,
|
||||
endswith: endswith,
|
||||
format: format,
|
||||
@@ -53,4 +55,9 @@ export function validateFunction(context: ParseContext, identifier: Token, argCo
|
||||
if (argCount > f.maxArgs) {
|
||||
throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier);
|
||||
}
|
||||
|
||||
// case function requires an odd number of arguments
|
||||
if (name === "case" && argCount % 2 === 0) {
|
||||
throw new ExpressionError(ErrorType.ErrorEvenParameters, identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {ExpressionData, Kind} from "../data/index.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const caseFunc: FunctionDefinition = {
|
||||
name: "case",
|
||||
description:
|
||||
"`case( pred1, val1, pred2, val2, ..., default )`\n\nEvaluates predicates in order and returns the value corresponding to the first predicate that evaluates to `true`. If no predicate matches, it returns the last argument as the default value.",
|
||||
minArgs: 3,
|
||||
maxArgs: Number.MAX_SAFE_INTEGER,
|
||||
call: (...args: ExpressionData[]): ExpressionData => {
|
||||
// Evaluate predicate-result pairs
|
||||
for (let i = 0; i < args.length - 1; i += 2) {
|
||||
const predicate = args[i];
|
||||
|
||||
// Predicate must be a boolean
|
||||
if (predicate.kind !== Kind.Boolean) {
|
||||
throw new Error("case predicate must evaluate to a boolean value");
|
||||
}
|
||||
|
||||
// If predicate is true, return the corresponding result
|
||||
if (predicate.value) {
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// No predicate matched, return default (last argument)
|
||||
return args[args.length - 1];
|
||||
}
|
||||
};
|
||||
Vendored
+157
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"case": [
|
||||
{
|
||||
"expr": "case(true, 'first', 'default')",
|
||||
"result": { "kind": "String", "value": "first" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', 'default')",
|
||||
"result": { "kind": "String", "value": "default" }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, 'first', false, 'second', 'default')",
|
||||
"result": { "kind": "String", "value": "first" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', true, 'second', 'default')",
|
||||
"result": { "kind": "String", "value": "second" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', false, 'second', 'default')",
|
||||
"result": { "kind": "String", "value": "default" }
|
||||
},
|
||||
{
|
||||
"expr": "case(1 == 1, 'equal', 'not equal')",
|
||||
"result": { "kind": "String", "value": "equal" }
|
||||
},
|
||||
{
|
||||
"expr": "case(1 == 2, 'equal', 'not equal')",
|
||||
"result": { "kind": "String", "value": "not equal" }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"ref": "refs/heads/main",
|
||||
"event_name": "push"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "String", "value": "main" }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"ref": "refs/heads/develop",
|
||||
"event_name": "pull_request"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "String", "value": "pr" }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"ref": "refs/heads/develop",
|
||||
"event_name": "push"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "String", "value": "other" }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, 123, 456)",
|
||||
"result": { "kind": "Number", "value": 123 }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 123, 456)",
|
||||
"result": { "kind": "Number", "value": 456 }
|
||||
},
|
||||
{
|
||||
"expr": "case(github.event == 'pull_request', 0, 1)",
|
||||
"contexts": {
|
||||
"github": {
|
||||
"event": "pull_request"
|
||||
}
|
||||
},
|
||||
"result": { "kind": "Number", "value": 0 }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 0, 1)",
|
||||
"result": { "kind": "Number", "value": 1 }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, false, true)",
|
||||
"result": { "kind": "Boolean", "value": false }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, false, true)",
|
||||
"result": { "kind": "Boolean", "value": true }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, '', 'default')",
|
||||
"result": { "kind": "String", "value": "" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', '')",
|
||||
"result": { "kind": "String", "value": "" }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, fromJSON('[1,2,3]'), 'default')",
|
||||
"result": { "kind": "Array", "value": [1, 2, 3] }
|
||||
},
|
||||
{
|
||||
"expr": "case(true, fromJSON('{\"key\":\"value\"}'), 'default')",
|
||||
"result": { "kind": "Object", "value": { "key": "value" } }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', false, 'second', false, 'third', false, 'fourth', 'default')",
|
||||
"result": { "kind": "String", "value": "default" }
|
||||
},
|
||||
{
|
||||
"expr": "case(false, 'first', false, 'second', true, 'third', false, 'fourth', 'default')",
|
||||
"result": { "kind": "String", "value": "third" }
|
||||
},
|
||||
{
|
||||
"expr": "case('not a boolean', 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(1, 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(null, 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(fromJSON('[]'), 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(fromJSON('{}'), 'first', 'default')",
|
||||
"err": {
|
||||
"kind": "evaluation",
|
||||
"value": "case predicate must evaluate to a boolean value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "case(true, 'first', false, 'second')",
|
||||
"err": {
|
||||
"kind": "parsing",
|
||||
"value": "Even number of parameters supplied, requires an odd number of parameters: 'case'. Located at position 1 within expression: case(true, 'first', false, 'second')"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.35",
|
||||
"version": "0.3.44",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -48,8 +48,8 @@
|
||||
"actions-languageserver": "./bin/actions-languageserver"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.35",
|
||||
"@actions/workflow-parser": "^0.3.35",
|
||||
"@actions/languageservice": "^0.3.44",
|
||||
"@actions/workflow-parser": "^0.3.44",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
|
||||
import {
|
||||
documentLinks,
|
||||
getCodeActions,
|
||||
getInlayHints,
|
||||
hover,
|
||||
validate,
|
||||
ValidationConfig
|
||||
} from "@actions/languageservice";
|
||||
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
||||
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {
|
||||
CodeAction,
|
||||
CodeActionKind,
|
||||
CodeActionParams,
|
||||
CompletionItem,
|
||||
Connection,
|
||||
DocumentLink,
|
||||
@@ -79,7 +89,10 @@ export function initConnection(connection: Connection) {
|
||||
documentLinkProvider: {
|
||||
resolveProvider: false
|
||||
},
|
||||
inlayHintProvider: true
|
||||
inlayHintProvider: true,
|
||||
codeActionProvider: {
|
||||
codeActionKinds: [CodeActionKind.QuickFix]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,7 +154,8 @@ export function initConnection(connection: Connection) {
|
||||
getDocument(documents, textDocument),
|
||||
client,
|
||||
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
|
||||
cache
|
||||
cache,
|
||||
featureFlags
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -177,6 +191,17 @@ export function initConnection(connection: Connection) {
|
||||
});
|
||||
});
|
||||
|
||||
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
|
||||
const document = getDocument(documents, params.textDocument);
|
||||
return getCodeActions({
|
||||
uri: params.textDocument.uri,
|
||||
documentContent: document.getText(),
|
||||
diagnostics: params.context.diagnostics,
|
||||
only: params.context.only,
|
||||
featureFlags
|
||||
});
|
||||
});
|
||||
|
||||
// Make the text document manager listen on the connection
|
||||
// for open, change and close text document events
|
||||
documents.listen(connection);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {complete} from "@actions/languageservice/complete";
|
||||
import type {FeatureFlags} from "@actions/expressions";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
@@ -15,11 +16,13 @@ export async function onCompletion(
|
||||
document: TextDocument,
|
||||
client: Octokit | undefined,
|
||||
repoContext: RepositoryContext | undefined,
|
||||
cache: TTLCache
|
||||
cache: TTLCache,
|
||||
featureFlags?: FeatureFlags
|
||||
): Promise<CompletionItem[]> {
|
||||
return await complete(document, position, {
|
||||
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
|
||||
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
|
||||
featureFlags,
|
||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||
return await connection.sendRequest(Requests.ReadFile, {path});
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.35",
|
||||
"version": "0.3.44",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -47,8 +47,8 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.35",
|
||||
"@actions/workflow-parser": "^0.3.35",
|
||||
"@actions/expressions": "^0.3.44",
|
||||
"@actions/workflow-parser": "^0.3.44",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
|
||||
import {CodeActionContext, CodeActionProvider} from "./types.js";
|
||||
import {getQuickfixProviders} from "./quickfix/quickfix-providers.js";
|
||||
|
||||
export interface CodeActionParams {
|
||||
uri: string;
|
||||
documentContent: string;
|
||||
diagnostics: Diagnostic[];
|
||||
only?: string[];
|
||||
featureFlags?: FeatureFlags;
|
||||
}
|
||||
|
||||
export function getCodeActions(params: CodeActionParams): CodeAction[] {
|
||||
const actions: CodeAction[] = [];
|
||||
const context: CodeActionContext = {
|
||||
uri: params.uri,
|
||||
documentContent: params.documentContent,
|
||||
featureFlags: params.featureFlags
|
||||
};
|
||||
|
||||
// Build providers map based on feature flags
|
||||
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
|
||||
[CodeActionKind.QuickFix, getQuickfixProviders(params.featureFlags)]
|
||||
// [CodeActionKind.Refactor, getRefactorProviders(params.featureFlags)],
|
||||
// [CodeActionKind.Source, getSourceProviders(params.featureFlags)],
|
||||
// etc
|
||||
]);
|
||||
|
||||
// Filter to requested kinds, or use all if none specified
|
||||
const requestedKinds = params.only;
|
||||
const kindsToCheck = requestedKinds
|
||||
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
|
||||
: [...providersByKind.keys()];
|
||||
|
||||
for (const diagnostic of params.diagnostics) {
|
||||
for (const kind of kindsToCheck) {
|
||||
const providers = providersByKind.get(kind) ?? [];
|
||||
for (const provider of providers) {
|
||||
if (provider.diagnosticCodes.includes(diagnostic.code)) {
|
||||
const action = provider.createCodeAction(context, diagnostic);
|
||||
if (action) {
|
||||
action.kind = kind;
|
||||
action.diagnostics = [diagnostic];
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export type {CodeActionContext, CodeActionProvider} from "./types.js";
|
||||
@@ -0,0 +1,245 @@
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {CodeAction, Position, TextEdit} from "vscode-languageserver-types";
|
||||
import {error} from "../../log.js";
|
||||
import {findToken} from "../../utils/find-token.js";
|
||||
import {getOrParseWorkflow} from "../../utils/workflow-cache.js";
|
||||
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js";
|
||||
import {CodeActionContext, CodeActionProvider} from "../types.js";
|
||||
|
||||
/**
|
||||
* Information extracted from a step token needed to generate edits
|
||||
*/
|
||||
interface StepInfo {
|
||||
/** Column where step keys start (1-indexed), e.g., the column of "uses:" */
|
||||
stepKeyColumn: number;
|
||||
/** End line of the step (1-indexed) */
|
||||
stepEndLine: number;
|
||||
/** Detected indent size (spaces per level) */
|
||||
indentSize: number;
|
||||
/** Information about existing with: block, if present */
|
||||
withInfo?: {
|
||||
keyColumn: number;
|
||||
keyEndLine: number;
|
||||
valueEndLine: number;
|
||||
hasChildren: boolean;
|
||||
/** Column of first child input (1-indexed), for indentation detection */
|
||||
firstChildColumn?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const addMissingInputsProvider: CodeActionProvider = {
|
||||
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
|
||||
|
||||
createCodeAction(context: CodeActionContext, diagnostic): CodeAction | undefined {
|
||||
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Parse the document to get the step token
|
||||
const stepInfo = getStepInfo(context, diagnostic.range.start);
|
||||
if (!stepInfo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const edits = createInputEdits(data.missingInputs, stepInfo);
|
||||
if (!edits || edits.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inputNames = data.missingInputs.map(i => i.name).join(", ");
|
||||
|
||||
return {
|
||||
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
|
||||
edit: {
|
||||
changes: {
|
||||
[context.uri]: edits
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the document and extract step information needed for generating edits.
|
||||
* Returns undefined if parsing fails or the step token cannot be found.
|
||||
*/
|
||||
function getStepInfo(context: CodeActionContext, diagnosticPosition: Position): StepInfo | undefined {
|
||||
// Parse the document (uses cache if available from validation)
|
||||
const file = {name: context.uri, content: context.documentContent};
|
||||
const parseResult = getOrParseWorkflow(file, context.uri);
|
||||
|
||||
if (!parseResult.value) {
|
||||
error("Failed to parse workflow for missing inputs quickfix");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Find the token at the diagnostic position
|
||||
const {path} = findToken(diagnosticPosition, parseResult.value);
|
||||
|
||||
// Walk up the path to find the step token (regular-step)
|
||||
const stepToken = findStepInPath(path);
|
||||
if (!stepToken) {
|
||||
error("Could not find step token for missing inputs quickfix");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return extractStepInfo(stepToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the step token (regular-step) in the token path
|
||||
*/
|
||||
function findStepInPath(path: TemplateToken[]): MappingToken | undefined {
|
||||
// Walk backwards through path to find the step
|
||||
for (let i = path.length - 1; i >= 0; i--) {
|
||||
if (path[i].definition?.key === "regular-step" && isMapping(path[i])) {
|
||||
return path[i] as MappingToken;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract position and indentation info from a step token
|
||||
*/
|
||||
function extractStepInfo(stepToken: MappingToken): StepInfo | undefined {
|
||||
if (!stepToken.range) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the column of the first key in the step
|
||||
let stepKeyColumn = stepToken.range.start.column;
|
||||
if (stepToken.count > 0) {
|
||||
const firstEntry = stepToken.get(0);
|
||||
if (firstEntry?.key.range) {
|
||||
stepKeyColumn = firstEntry.key.range.start.column;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the with: block if present
|
||||
let withKey: ScalarToken | undefined;
|
||||
let withToken: TemplateToken | undefined;
|
||||
for (const {key, value} of stepToken) {
|
||||
if (key.toString() === "with") {
|
||||
withKey = key;
|
||||
withToken = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate indent size
|
||||
let indentSize = 2; // Default
|
||||
let withInfo: StepInfo["withInfo"];
|
||||
|
||||
if (withKey?.range && withToken?.range) {
|
||||
// Has with: block - extract its info
|
||||
const hasChildren = isMapping(withToken) && withToken.count > 0;
|
||||
let firstChildColumn: number | undefined;
|
||||
|
||||
if (hasChildren) {
|
||||
const firstChild = (withToken as MappingToken).get(0);
|
||||
if (firstChild?.key.range) {
|
||||
firstChildColumn = firstChild.key.range.start.column;
|
||||
// Detect indent size from with: children
|
||||
indentSize = firstChildColumn - withKey.range.start.column;
|
||||
}
|
||||
}
|
||||
|
||||
withInfo = {
|
||||
keyColumn: withKey.range.start.column,
|
||||
keyEndLine: withKey.range.end.line,
|
||||
valueEndLine: withToken.range.end.line,
|
||||
hasChildren,
|
||||
firstChildColumn
|
||||
};
|
||||
} else {
|
||||
// No with: block - detect indent size using heuristics
|
||||
// Based on the step key column position, estimate indent size
|
||||
// 2-space indent files typically have step keys at column 7
|
||||
// 4-space indent files typically have step keys at column 15
|
||||
const zeroIndexedCol = stepKeyColumn - 1;
|
||||
if (zeroIndexedCol >= 10) {
|
||||
indentSize = 4;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stepKeyColumn,
|
||||
stepEndLine: stepToken.range.end.line,
|
||||
indentSize,
|
||||
withInfo
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text edits to add missing inputs
|
||||
*/
|
||||
function createInputEdits(missingInputs: MissingInputsDiagnosticData["missingInputs"], stepInfo: StepInfo): TextEdit[] {
|
||||
const formatInputLines = (indent: string) =>
|
||||
missingInputs.map(input => {
|
||||
const value = input.default ?? '""';
|
||||
return `${indent}${input.name}: ${value}`;
|
||||
});
|
||||
|
||||
if (stepInfo.withInfo) {
|
||||
// `with:` exists - add inputs to existing block
|
||||
const withIndent = stepInfo.withInfo.keyColumn - 1; // 0-indexed
|
||||
const inputIndentSize = stepInfo.withInfo.firstChildColumn
|
||||
? stepInfo.withInfo.firstChildColumn - stepInfo.withInfo.keyColumn
|
||||
: stepInfo.indentSize;
|
||||
|
||||
const inputIndent = " ".repeat(withIndent + inputIndentSize);
|
||||
const inputLines = formatInputLines(inputIndent);
|
||||
|
||||
// Calculate insert position
|
||||
let insertLine: number;
|
||||
if (stepInfo.withInfo.hasChildren) {
|
||||
// Insert after the last child (at end of with: block)
|
||||
// valueEndLine is 1-indexed, we want 0-indexed for Position
|
||||
insertLine = stepInfo.withInfo.valueEndLine - 1;
|
||||
} else {
|
||||
// Empty with: block - insert on the next line after with:
|
||||
// keyEndLine is 1-indexed, convert to 0-indexed and go to next line
|
||||
insertLine = stepInfo.withInfo.keyEndLine;
|
||||
}
|
||||
|
||||
const insertPosition: Position = {
|
||||
line: insertLine,
|
||||
character: 0
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
range: {start: insertPosition, end: insertPosition},
|
||||
newText: inputLines.map(line => line + "\n").join("")
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// No `with:` key - add `with:` at the same level as other step keys
|
||||
const withKeyIndent = stepInfo.stepKeyColumn - 1; // 0-indexed (columns are 1-based)
|
||||
|
||||
const withIndent = " ".repeat(withKeyIndent);
|
||||
const inputIndent = " ".repeat(withKeyIndent + stepInfo.indentSize);
|
||||
const inputLines = formatInputLines(inputIndent);
|
||||
|
||||
const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");
|
||||
|
||||
// Insert at end of step
|
||||
// stepEndLine is 1-indexed, we want 0-indexed and insert before the line after
|
||||
const insertPosition: Position = {
|
||||
line: stepInfo.stepEndLine - 1,
|
||||
character: 0
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
range: {start: insertPosition, end: insertPosition},
|
||||
newText
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {CodeActionProvider} from "../types.js";
|
||||
import {addMissingInputsProvider} from "./add-missing-inputs.js";
|
||||
|
||||
export function getQuickfixProviders(featureFlags?: FeatureFlags): CodeActionProvider[] {
|
||||
const providers: CodeActionProvider[] = [];
|
||||
|
||||
if (featureFlags?.isEnabled("missingInputsQuickfix")) {
|
||||
providers.push(addMissingInputsProvider);
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as path from "path";
|
||||
import {fileURLToPath} from "url";
|
||||
import {loadTestCases, runTestCase} from "./runner.js";
|
||||
import {ValidationConfig} from "../../validate.js";
|
||||
import {ActionMetadata, ActionReference} from "../../action.js";
|
||||
import {clearCache} from "../../utils/workflow-cache.js";
|
||||
|
||||
// ESM-compatible __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Mock action metadata provider for tests
|
||||
const validationConfig: ValidationConfig = {
|
||||
actionsMetadataProvider: {
|
||||
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
|
||||
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
|
||||
|
||||
const metadata: Record<string, ActionMetadata> = {
|
||||
"actions/cache@v1": {
|
||||
name: "Cache",
|
||||
description: "Cache dependencies",
|
||||
inputs: {
|
||||
path: {
|
||||
description: "A list of files to cache",
|
||||
required: true
|
||||
},
|
||||
key: {
|
||||
description: "Cache key",
|
||||
required: true
|
||||
},
|
||||
"restore-keys": {
|
||||
description: "Restore keys",
|
||||
required: false
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions/setup-node@v3": {
|
||||
name: "Setup Node",
|
||||
description: "Setup Node.js",
|
||||
inputs: {
|
||||
"node-version": {
|
||||
description: "Node version",
|
||||
required: true,
|
||||
default: "16"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return Promise.resolve(metadata[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Point to the source testdata directory
|
||||
const testdataDir = path.join(__dirname, "testdata");
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("code action golden tests", () => {
|
||||
const testCases = loadTestCases(testdataDir);
|
||||
|
||||
if (testCases.length === 0) {
|
||||
it.todo("no test cases found - add .yml files to testdata/");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, async () => {
|
||||
const result = await runTestCase(testCase, validationConfig);
|
||||
|
||||
if (!result.passed) {
|
||||
let errorMessage = result.error || "Test failed";
|
||||
|
||||
if (result.expected !== undefined && result.actual !== undefined) {
|
||||
errorMessage += "\n\n";
|
||||
errorMessage += "=== EXPECTED (golden file) ===\n";
|
||||
errorMessage += result.expected;
|
||||
errorMessage += "\n\n";
|
||||
errorMessage += "=== ACTUAL ===\n";
|
||||
errorMessage += result.actual;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {TextEdit} from "vscode-languageserver-types";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {validate, ValidationConfig} from "../../validate.js";
|
||||
import {getCodeActions, CodeActionParams} from "../code-actions.js";
|
||||
|
||||
// Marker pattern: # want "diagnostic message" fix="code-action-name"
|
||||
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
|
||||
|
||||
export interface TestCase {
|
||||
name: string;
|
||||
inputPath: string;
|
||||
goldenPath: string;
|
||||
input: string;
|
||||
golden: string;
|
||||
markers: Marker[];
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
line: number;
|
||||
message: string;
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markers from input file content
|
||||
*/
|
||||
export function parseMarkers(content: string): Marker[] {
|
||||
const lines = content.split("\n");
|
||||
const markers: Marker[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(MARKER_PATTERN);
|
||||
if (match) {
|
||||
markers.push({
|
||||
line: i,
|
||||
message: match[1],
|
||||
fix: match[2]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markers from content (for processing)
|
||||
*/
|
||||
export function stripMarkers(content: string): string {
|
||||
return content
|
||||
.split("\n")
|
||||
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all test cases from a testdata directory
|
||||
*/
|
||||
export function loadTestCases(testdataDir: string): TestCase[] {
|
||||
const testCases: TestCase[] = [];
|
||||
|
||||
function walkDir(dir: string) {
|
||||
const entries = fs.readdirSync(dir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(".golden.yml")) {
|
||||
const goldenPath = fullPath.replace(".yml", ".golden.yml");
|
||||
|
||||
if (fs.existsSync(goldenPath)) {
|
||||
const input = fs.readFileSync(fullPath, "utf-8");
|
||||
const golden = fs.readFileSync(goldenPath, "utf-8");
|
||||
|
||||
testCases.push({
|
||||
name: path.relative(testdataDir, fullPath),
|
||||
inputPath: fullPath,
|
||||
goldenPath,
|
||||
input,
|
||||
golden,
|
||||
markers: parseMarkers(input)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDir(testdataDir);
|
||||
return testCases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply text edits to a document
|
||||
*/
|
||||
export function applyEdits(content: string, edits: TextEdit[]): string {
|
||||
// Sort edits in reverse order by position to apply from bottom to top
|
||||
const sortedEdits = [...edits].sort((a, b) => {
|
||||
if (b.range.start.line !== a.range.start.line) {
|
||||
return b.range.start.line - a.range.start.line;
|
||||
}
|
||||
return b.range.start.character - a.range.start.character;
|
||||
});
|
||||
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const edit of sortedEdits) {
|
||||
const startLine = edit.range.start.line;
|
||||
const startChar = edit.range.start.character;
|
||||
const endLine = edit.range.end.line;
|
||||
const endChar = edit.range.end.character;
|
||||
|
||||
const before = lines[startLine].slice(0, startChar);
|
||||
const after = lines[endLine].slice(endChar);
|
||||
|
||||
const newLines = edit.newText.split("\n");
|
||||
newLines[0] = before + newLines[0];
|
||||
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
|
||||
|
||||
lines.splice(startLine, endLine - startLine + 1, ...newLines);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single test case
|
||||
*/
|
||||
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
|
||||
const strippedInput = stripMarkers(testCase.input);
|
||||
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
|
||||
|
||||
// 1. Validate and get diagnostics
|
||||
const diagnostics = await validate(document, validationConfig);
|
||||
|
||||
// 2. Verify all expected diagnostics are present
|
||||
const missingDiagnostics: string[] = [];
|
||||
for (const marker of testCase.markers) {
|
||||
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
|
||||
if (!found) {
|
||||
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingDiagnostics.length > 0) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
|
||||
"\n "
|
||||
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Collect all edits from all matching code actions
|
||||
const allEdits: TextEdit[] = [];
|
||||
|
||||
for (const marker of testCase.markers) {
|
||||
if (!marker.fix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
|
||||
|
||||
if (!diagnostic) {
|
||||
continue; // Already reported above
|
||||
}
|
||||
|
||||
const params: CodeActionParams = {
|
||||
uri: document.uri,
|
||||
documentContent: strippedInput,
|
||||
diagnostics: [diagnostic],
|
||||
featureFlags: new FeatureFlags({all: true})
|
||||
};
|
||||
|
||||
const actions = getCodeActions(params);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- marker.fix is checked at the start of the loop
|
||||
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
|
||||
|
||||
if (!matchingAction) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${
|
||||
actions.map(a => a.title).join(", ") || "(none)"
|
||||
}`
|
||||
};
|
||||
}
|
||||
|
||||
if (!matchingAction.edit?.changes) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: `Code action "${marker.fix}" has no edits`
|
||||
};
|
||||
}
|
||||
|
||||
const edits = matchingAction.edit.changes[document.uri] || [];
|
||||
allEdits.push(...edits);
|
||||
}
|
||||
|
||||
// 4. Apply all edits and compare to golden file
|
||||
const actualOutput = applyEdits(strippedInput, allEdits);
|
||||
const expectedOutput = testCase.golden;
|
||||
|
||||
if (actualOutput.trim() !== expectedOutput.trim()) {
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: false,
|
||||
error: "Output does not match golden file",
|
||||
expected: expectedOutput,
|
||||
actual: actualOutput
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: testCase.name,
|
||||
passed: true
|
||||
};
|
||||
}
|
||||
languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.golden.yml
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ""
|
||||
key: ""
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
restore-keys: ${{ runner.os }}-
|
||||
path: ""
|
||||
key: ""
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
restore-keys: ${{ runner.os }}-
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ""
|
||||
key: ""
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ""
|
||||
key: ""
|
||||
@@ -0,0 +1,6 @@
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||
@@ -0,0 +1,23 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
|
||||
|
||||
export interface CodeActionContext {
|
||||
uri: string;
|
||||
documentContent: string;
|
||||
featureFlags?: FeatureFlags;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider that can produce a code action for a given diagnostic
|
||||
*/
|
||||
export interface CodeActionProvider {
|
||||
/**
|
||||
* The diagnostic codes this provider handles
|
||||
*/
|
||||
diagnosticCodes: (string | number | undefined)[];
|
||||
|
||||
/**
|
||||
* Create a code action for the diagnostic, if applicable
|
||||
*/
|
||||
createCodeAction(context: CodeActionContext, diagnostic: Diagnostic): CodeAction | undefined;
|
||||
}
|
||||
@@ -1,17 +1,11 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {complete, CompletionConfig} from "./complete";
|
||||
import {complete} from "./complete";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// Config to enable action scaffolding snippets
|
||||
const scaffoldingConfig: CompletionConfig = {
|
||||
featureFlags: new FeatureFlags({actionScaffoldingSnippets: true})
|
||||
};
|
||||
|
||||
describe("complete action files", () => {
|
||||
function createActionDocument(
|
||||
content: string,
|
||||
@@ -140,6 +134,49 @@ runs:
|
||||
expect(labels).toContain("arch");
|
||||
expect(labels).toContain("temp");
|
||||
});
|
||||
|
||||
it("completes if expression value for composite run step", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: |
|
||||
run: echo "hello"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
// Should show expression-related completions (status functions and contexts)
|
||||
expect(labels).toContain("always");
|
||||
expect(labels).toContain("success");
|
||||
expect(labels).toContain("failure");
|
||||
expect(labels).toContain("cancelled");
|
||||
expect(labels).toContain("runner");
|
||||
expect(labels).toContain("github");
|
||||
expect(labels).toContain("inputs");
|
||||
expect(labels).toContain("steps");
|
||||
});
|
||||
|
||||
it("completes if expression value for composite uses step", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: |
|
||||
uses: actions/checkout@v4`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
// Should show expression-related completions
|
||||
expect(labels).toContain("always");
|
||||
expect(labels).toContain("success");
|
||||
expect(labels).toContain("failure");
|
||||
expect(labels).toContain("cancelled");
|
||||
expect(labels).toContain("runner");
|
||||
expect(labels).toContain("github");
|
||||
});
|
||||
});
|
||||
|
||||
describe("top-level completions", () => {
|
||||
@@ -213,6 +250,85 @@ runs:
|
||||
expect(labels).not.toContain("entrypoint");
|
||||
});
|
||||
|
||||
it("filters runs keys for node24 actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node24
|
||||
|`);
|
||||
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("completes pre-if expression value for node actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node24
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: |`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
// Should show expression-related completions (context functions and namespaces)
|
||||
expect(labels).toContain("always");
|
||||
expect(labels).toContain("success");
|
||||
expect(labels).toContain("failure");
|
||||
expect(labels).toContain("cancelled");
|
||||
expect(labels).toContain("runner");
|
||||
expect(labels).toContain("github");
|
||||
expect(labels).toContain("inputs");
|
||||
expect(labels).toContain("hashFiles");
|
||||
});
|
||||
|
||||
it("completes post-if expression value for node actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node24
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: |`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
// Should show expression-related completions
|
||||
expect(labels).toContain("always");
|
||||
expect(labels).toContain("runner");
|
||||
expect(labels).toContain("hashFiles");
|
||||
});
|
||||
|
||||
it("completes pre-if expression value for docker actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: docker
|
||||
image: docker://alpine
|
||||
pre-entrypoint: setup.sh
|
||||
pre-if: |`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
// Should show expression-related completions
|
||||
expect(labels).toContain("always");
|
||||
expect(labels).toContain("runner");
|
||||
expect(labels).toContain("github");
|
||||
expect(labels).toContain("hashFiles");
|
||||
});
|
||||
|
||||
it("filters runs keys for composite actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
@@ -265,8 +381,8 @@ runs:
|
||||
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 should have a sortText that makes it sort after snippets
|
||||
expect(usingCompletion?.sortText).toBe("9_using");
|
||||
});
|
||||
|
||||
it("completes step keys inside composite action steps", async () => {
|
||||
@@ -403,7 +519,7 @@ runs:
|
||||
describe("action scaffolding snippets", () => {
|
||||
it("offers full scaffolding snippets in empty file", async () => {
|
||||
const [doc, position] = createActionDocument(`|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("Node.js Action");
|
||||
@@ -419,7 +535,7 @@ runs:
|
||||
it("offers full scaffolding snippets when no name or description exists", async () => {
|
||||
const [doc, position] = createActionDocument(`author: me
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
||||
expect(nodeSnippet).toBeDefined();
|
||||
@@ -430,7 +546,7 @@ runs:
|
||||
it("offers runs-only snippets when name exists", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
||||
expect(nodeSnippet).toBeDefined();
|
||||
@@ -442,7 +558,7 @@ runs:
|
||||
it("offers runs-only snippets when description exists", async () => {
|
||||
const [doc, position] = createActionDocument(`description: Does something
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
||||
expect(compositeSnippet).toBeDefined();
|
||||
@@ -457,7 +573,7 @@ description: Test
|
||||
runs:
|
||||
using: composite
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).not.toContain("Node.js Action");
|
||||
@@ -470,7 +586,7 @@ runs:
|
||||
description: Test
|
||||
runs:
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("Node.js Action");
|
||||
@@ -484,7 +600,7 @@ description: Test
|
||||
runs:
|
||||
steps: []
|
||||
|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).not.toContain("Node.js Action");
|
||||
@@ -499,7 +615,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- |`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).not.toContain("Node.js Action");
|
||||
@@ -509,7 +625,7 @@ runs:
|
||||
|
||||
it("Node.js snippet contains expected content", async () => {
|
||||
const [doc, position] = createActionDocument(`|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
||||
const text = (nodeSnippet?.textEdit as {newText: string})?.newText;
|
||||
@@ -522,7 +638,7 @@ runs:
|
||||
|
||||
it("Composite snippet contains expected content", async () => {
|
||||
const [doc, position] = createActionDocument(`|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
||||
const text = (compositeSnippet?.textEdit as {newText: string})?.newText;
|
||||
@@ -534,7 +650,7 @@ runs:
|
||||
|
||||
it("Docker snippet contains expected content", async () => {
|
||||
const [doc, position] = createActionDocument(`|`);
|
||||
const completions = await complete(doc, position, scaffoldingConfig);
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const dockerSnippet = completions.find(c => c.label === "Docker Action");
|
||||
const text = (dockerSnippet?.textEdit as {newText: string})?.newText;
|
||||
@@ -544,14 +660,30 @@ runs:
|
||||
expect(text).toContain("entrypoint:");
|
||||
});
|
||||
|
||||
it("does not offer snippets when feature flag is disabled", async () => {
|
||||
it("replaces typed text when selecting scaffolding snippet", async () => {
|
||||
// User typed "compo" and then triggered completion
|
||||
const [doc, position] = createActionDocument(`compo|`);
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
||||
expect(compositeSnippet).toBeDefined();
|
||||
|
||||
// The textEdit should replace "compo", not insert after it
|
||||
const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}};
|
||||
expect(textEdit.range.start.character).toBe(0); // Start of "compo"
|
||||
expect(textEdit.range.end.character).toBe(5); // End of "compo"
|
||||
});
|
||||
|
||||
it("handles empty file with no typed text", async () => {
|
||||
const [doc, position] = createActionDocument(`|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).not.toContain("Node.js Action");
|
||||
expect(labels).not.toContain("Composite Action");
|
||||
expect(labels).not.toContain("Docker Action");
|
||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
||||
const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}};
|
||||
|
||||
// Zero-length range is fine when there's nothing to replace
|
||||
expect(textEdit.range.start.character).toBe(0);
|
||||
expect(textEdit.range.end.character).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {Position} from "vscode-languageserver-textdocument";
|
||||
import {CompletionItem, CompletionItemKind, InsertTextFormat, TextEdit} from "vscode-languageserver-types";
|
||||
import {CompletionItem, CompletionItemKind, InsertTextFormat, Range, TextEdit} from "vscode-languageserver-types";
|
||||
import {Value} from "./value-providers/config.js";
|
||||
|
||||
/**
|
||||
@@ -53,9 +53,6 @@ runs:
|
||||
# const greeting = \\\`Hello \\\${name}\\\`;
|
||||
# console.log(greeting);
|
||||
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
|
||||
#
|
||||
# For JavaScript actions with @actions/toolkit, see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_NODEJS_RUNS = `inputs:
|
||||
@@ -83,12 +80,12 @@ runs:
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_NODEJS_USING = `# For more on JavaScript actions (including @actions/toolkit), see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
||||
using: node24
|
||||
main: index.js
|
||||
# Sample index.js (vanilla JS, no build required):
|
||||
#
|
||||
# console.log('Hello World');
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
||||
using: node24
|
||||
main: index.js
|
||||
# Sample index.js (vanilla JS, no build required):
|
||||
#
|
||||
# console.log('Hello World');
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_COMPOSITE_FULL = `name: '\${1:Action Name}'
|
||||
@@ -115,9 +112,9 @@ runs:
|
||||
env:
|
||||
INPUT_NAME: \\\${{ inputs.name }}
|
||||
run: |
|
||||
GREETING="Hello $INPUT_NAME"
|
||||
echo "$GREETING"
|
||||
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
|
||||
GREETING="Hello \\$INPUT_NAME"
|
||||
echo "\\$GREETING"
|
||||
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_COMPOSITE_RUNS = `inputs:
|
||||
@@ -141,17 +138,17 @@ runs:
|
||||
env:
|
||||
INPUT_NAME: \\\${{ inputs.name }}
|
||||
run: |
|
||||
GREETING="Hello $INPUT_NAME"
|
||||
echo "$GREETING"
|
||||
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
|
||||
GREETING="Hello \\$INPUT_NAME"
|
||||
echo "\\$GREETING"
|
||||
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_COMPOSITE_USING = `# For more on composite actions, see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
run: echo "Hello World"
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
run: echo "Hello World"
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_DOCKER_FULL = `name: '\${1:Action Name}'
|
||||
@@ -179,9 +176,9 @@ runs:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
GREETING="Hello $INPUT_NAME"
|
||||
echo "$GREETING"
|
||||
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
|
||||
GREETING="Hello \\$INPUT_NAME"
|
||||
echo "\\$GREETING"
|
||||
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_DOCKER_RUNS = `inputs:
|
||||
@@ -206,20 +203,20 @@ runs:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
GREETING="Hello $INPUT_NAME"
|
||||
echo "$GREETING"
|
||||
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
|
||||
GREETING="Hello \\$INPUT_NAME"
|
||||
echo "\\$GREETING"
|
||||
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
|
||||
`;
|
||||
|
||||
const ACTION_SNIPPET_DOCKER_USING = `# For more on Docker actions, see:
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
|
||||
using: docker
|
||||
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
|
||||
image: '\${1:docker://alpine:3.20}'
|
||||
entrypoint: '\${2:sh}'
|
||||
args:
|
||||
- -c
|
||||
- echo "Hello World"
|
||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
|
||||
using: docker
|
||||
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
|
||||
image: '\${1:docker://alpine:3.20}'
|
||||
entrypoint: '\${2:sh}'
|
||||
args:
|
||||
- -c
|
||||
- echo "Hello World"
|
||||
`;
|
||||
|
||||
/**
|
||||
@@ -282,7 +279,7 @@ export function filterActionRunsCompletions(values: Value[], path: TemplateToken
|
||||
// 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, sortText: "9_using"}; // Sort after snippets (0_, 1_, 2_)
|
||||
}
|
||||
return v;
|
||||
});
|
||||
@@ -320,7 +317,8 @@ export function filterActionRunsCompletions(values: Value[], path: TemplateToken
|
||||
export function getActionScaffoldingSnippets(
|
||||
root: TemplateToken | undefined,
|
||||
path: TemplateToken[],
|
||||
position: Position
|
||||
position: Position,
|
||||
replaceRange?: Range
|
||||
): CompletionItem[] {
|
||||
// Get the runs mapping from the root, if it exists
|
||||
let runsMapping: MappingToken | undefined;
|
||||
@@ -351,24 +349,27 @@ export function getActionScaffoldingSnippets(
|
||||
return [
|
||||
createSnippetCompletion(
|
||||
"Node.js Action",
|
||||
"Scaffold a Node.js action",
|
||||
"Scaffold a Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
|
||||
ACTION_SNIPPET_NODEJS_USING,
|
||||
position,
|
||||
"1_nodejs"
|
||||
"0_nodejs",
|
||||
replaceRange
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Composite Action",
|
||||
"Scaffold a composite action",
|
||||
"Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
|
||||
ACTION_SNIPPET_COMPOSITE_USING,
|
||||
position,
|
||||
"2_composite"
|
||||
"1_composite",
|
||||
replaceRange
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Docker Action",
|
||||
"Scaffold a Docker action",
|
||||
"Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
|
||||
ACTION_SNIPPET_DOCKER_USING,
|
||||
position,
|
||||
"3_docker"
|
||||
"2_docker",
|
||||
replaceRange
|
||||
)
|
||||
];
|
||||
}
|
||||
@@ -396,24 +397,27 @@ export function getActionScaffoldingSnippets(
|
||||
return [
|
||||
createSnippetCompletion(
|
||||
"Node.js Action",
|
||||
"Scaffold a Node.js action",
|
||||
"Scaffold a Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
|
||||
ACTION_SNIPPET_NODEJS_RUNS,
|
||||
position,
|
||||
"1_nodejs"
|
||||
"1_nodejs",
|
||||
replaceRange
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Composite Action",
|
||||
"Scaffold a composite action",
|
||||
"Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
|
||||
ACTION_SNIPPET_COMPOSITE_RUNS,
|
||||
position,
|
||||
"2_composite"
|
||||
"2_composite",
|
||||
replaceRange
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Docker Action",
|
||||
"Scaffold a Docker action",
|
||||
"Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
|
||||
ACTION_SNIPPET_DOCKER_RUNS,
|
||||
position,
|
||||
"3_docker"
|
||||
"3_docker",
|
||||
replaceRange
|
||||
)
|
||||
];
|
||||
}
|
||||
@@ -422,24 +426,27 @@ export function getActionScaffoldingSnippets(
|
||||
return [
|
||||
createSnippetCompletion(
|
||||
"Node.js Action",
|
||||
"Scaffold a complete Node.js action",
|
||||
"Scaffold a complete Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
|
||||
ACTION_SNIPPET_NODEJS_FULL,
|
||||
position,
|
||||
"1_nodejs"
|
||||
"1_nodejs",
|
||||
replaceRange
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Composite Action",
|
||||
"Scaffold a complete composite action",
|
||||
"Scaffold a complete composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
|
||||
ACTION_SNIPPET_COMPOSITE_FULL,
|
||||
position,
|
||||
"2_composite"
|
||||
"2_composite",
|
||||
replaceRange
|
||||
),
|
||||
createSnippetCompletion(
|
||||
"Docker Action",
|
||||
"Scaffold a complete Docker action",
|
||||
"Scaffold a complete Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
|
||||
ACTION_SNIPPET_DOCKER_FULL,
|
||||
position,
|
||||
"3_docker"
|
||||
"3_docker",
|
||||
replaceRange
|
||||
)
|
||||
];
|
||||
}
|
||||
@@ -452,10 +459,15 @@ function createSnippetCompletion(
|
||||
description: string,
|
||||
snippetText: string,
|
||||
position: Position,
|
||||
sortText: string
|
||||
sortText: string,
|
||||
replaceRange?: Range
|
||||
): CompletionItem {
|
||||
// Use replace if we have a range, otherwise insert at position
|
||||
const textEdit = replaceRange ? TextEdit.replace(replaceRange, snippetText) : TextEdit.insert(position, snippetText);
|
||||
|
||||
return {
|
||||
label,
|
||||
labelDetails: {description: "snippet"},
|
||||
kind: CompletionItemKind.Snippet,
|
||||
documentation: {
|
||||
kind: "markdown",
|
||||
@@ -463,6 +475,6 @@ function createSnippetCompletion(
|
||||
},
|
||||
insertTextFormat: InsertTextFormat.Snippet,
|
||||
sortText,
|
||||
textEdit: TextEdit.insert(position, snippetText)
|
||||
textEdit
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
|
||||
import {CompletionItem, CompletionItemKind, MarkupContent} from "vscode-languageserver-types";
|
||||
import {complete, getExpressionInput} from "./complete.js";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
import {registerLogger} from "./log.js";
|
||||
@@ -68,12 +68,15 @@ describe("expressions", () => {
|
||||
describe("top-level auto-complete", () => {
|
||||
it("single region", async () => {
|
||||
const input = "run-name: ${{ | }}";
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -108,12 +111,15 @@ describe("expressions", () => {
|
||||
|
||||
it("single region with existing input", async () => {
|
||||
const input = "run-name: ${{ g| }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -126,12 +132,15 @@ describe("expressions", () => {
|
||||
|
||||
it("single region with existing condition", async () => {
|
||||
const input = "run-name: ${{ g| == 'test' }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -144,12 +153,15 @@ describe("expressions", () => {
|
||||
|
||||
it("multiple regions with partial function", async () => {
|
||||
const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -162,12 +174,15 @@ describe("expressions", () => {
|
||||
|
||||
it("multiple regions - first region", async () => {
|
||||
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -180,12 +195,15 @@ describe("expressions", () => {
|
||||
|
||||
it("multiple regions", async () => {
|
||||
const input = "run-name: test-${{ github }}-${{ | }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"github",
|
||||
"inputs",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -395,6 +413,36 @@ jobs:
|
||||
|
||||
expect(result.map(x => x.label)).toEqual(["event"]);
|
||||
});
|
||||
|
||||
it("includes both contexts and extension functions", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
if: |`;
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
const labels = result.map(x => x.label);
|
||||
|
||||
// Context namespaces should be present
|
||||
expect(labels).toContain("github");
|
||||
expect(labels).toContain("runner");
|
||||
expect(labels).toContain("env");
|
||||
expect(labels).toContain("steps");
|
||||
|
||||
// Extension functions should be present (from schema context array)
|
||||
expect(labels).toContain("hashFiles");
|
||||
expect(labels).toContain("always");
|
||||
expect(labels).toContain("success");
|
||||
expect(labels).toContain("failure");
|
||||
expect(labels).toContain("cancelled");
|
||||
|
||||
// Built-in functions should be present
|
||||
expect(labels).toContain("toJson");
|
||||
expect(labels).toContain("fromJson");
|
||||
expect(labels).toContain("contains");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1126,7 +1174,9 @@ jobs:
|
||||
run: echo hi
|
||||
`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig
|
||||
});
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"env",
|
||||
"github",
|
||||
@@ -1139,6 +1189,7 @@ jobs:
|
||||
"steps",
|
||||
"strategy",
|
||||
"vars",
|
||||
"case",
|
||||
"contains",
|
||||
"endsWith",
|
||||
"format",
|
||||
@@ -1250,6 +1301,7 @@ jobs:
|
||||
expect(hashFiles).toBeDefined();
|
||||
expect(hashFiles!.kind).toBe(CompletionItemKind.Function);
|
||||
expect(hashFiles!.insertText).toBe("hashFiles()");
|
||||
expect((hashFiles!.documentation as MarkupContent)?.value).toContain("Returns a single hash for the set of files");
|
||||
|
||||
// Not a function
|
||||
const github = result.find(x => x.label === "github");
|
||||
|
||||
@@ -895,4 +895,17 @@ jobs:
|
||||
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expression completions", () => {
|
||||
it("includes case function", async () => {
|
||||
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
// Expression completions starting with 'c': case, contains
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("case");
|
||||
expect(labels).toContain("contains");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {complete as completeExpression, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
||||
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||
import {FunctionInfo} from "@actions/expressions/funcs/info";
|
||||
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 {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||
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";
|
||||
@@ -19,6 +21,7 @@ import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit}
|
||||
import {filterActionRunsCompletions, getActionScaffoldingSnippets} from "./complete-action.js";
|
||||
import {ContextProviderConfig} from "./context-providers/config.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 {validatorFunctions} from "./expression-validation/functions.js";
|
||||
@@ -121,18 +124,24 @@ export async function complete(
|
||||
}
|
||||
|
||||
// Expression completions
|
||||
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
|
||||
if (token && (isBasicExpression(token) || isPotentiallyExpression(token, isAction))) {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const {namedContexts, functions: extensionFunctions} = splitAllowedContext(allowedContext);
|
||||
const context = isAction
|
||||
? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion)
|
||||
? getActionExpressionContext(namedContexts, config?.contextProviderConfig, actionContext, Mode.Completion)
|
||||
: await getWorkflowExpressionContext(
|
||||
allowedContext,
|
||||
namedContexts,
|
||||
config?.contextProviderConfig,
|
||||
workflowContext,
|
||||
Mode.Completion
|
||||
);
|
||||
|
||||
return getExpressionCompletionItems(token, context, newPos);
|
||||
// Populate function descriptions for completion display
|
||||
for (const func of extensionFunctions) {
|
||||
func.description = getFunctionDescription(func.name);
|
||||
}
|
||||
|
||||
return getExpressionCompletionItems(token, context, extensionFunctions, newPos, config?.featureFlags);
|
||||
}
|
||||
|
||||
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
|
||||
@@ -158,12 +167,6 @@ export async function complete(
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
||||
values.push(...escapeHatches);
|
||||
|
||||
// Get action scaffolding snippets if applicable
|
||||
let actionSnippets: CompletionItem[] = [];
|
||||
if (isAction && config?.featureFlags?.isEnabled("actionScaffoldingSnippets")) {
|
||||
actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position);
|
||||
}
|
||||
|
||||
// Figure out what text to replace when the user picks a completion.
|
||||
// For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`.
|
||||
let replaceRange: Range | undefined;
|
||||
@@ -191,6 +194,12 @@ export async function complete(
|
||||
}
|
||||
}
|
||||
|
||||
// Get action scaffolding snippets if applicable
|
||||
let actionSnippets: CompletionItem[] = [];
|
||||
if (isAction) {
|
||||
actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position, replaceRange);
|
||||
}
|
||||
|
||||
// Convert values to LSP CompletionItems
|
||||
const completionItems = values.map(value => {
|
||||
const newText = value.insertText || value.label;
|
||||
@@ -521,7 +530,9 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT
|
||||
function getExpressionCompletionItems(
|
||||
token: TemplateToken,
|
||||
context: DescriptionDictionary,
|
||||
pos: Position
|
||||
extensionFunctions: FunctionInfo[],
|
||||
pos: Position,
|
||||
featureFlags?: FeatureFlags
|
||||
): CompletionItem[] {
|
||||
if (!token.range) {
|
||||
return [];
|
||||
@@ -540,8 +551,8 @@ function getExpressionCompletionItems(
|
||||
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
||||
|
||||
try {
|
||||
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
|
||||
mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
||||
return completeExpression(expressionInput, context, extensionFunctions, validatorFunctions, featureFlags).map(
|
||||
item => mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
||||
);
|
||||
} catch (e) {
|
||||
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
||||
|
||||
@@ -198,9 +198,13 @@ function getDefaultActionContext(
|
||||
case "runner":
|
||||
return getRunnerContext();
|
||||
|
||||
case "env":
|
||||
// Actions can access env but we don't have runtime values
|
||||
return new DescriptionDictionary();
|
||||
case "env": {
|
||||
// Actions can access env but we don't know what env vars the calling workflow defines
|
||||
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
|
||||
const envContext = new DescriptionDictionary();
|
||||
envContext.complete = false;
|
||||
return envContext;
|
||||
}
|
||||
|
||||
case "job": {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||
@@ -218,9 +222,13 @@ function getDefaultActionContext(
|
||||
case "strategy":
|
||||
return getStrategyContext();
|
||||
|
||||
case "matrix":
|
||||
// Actions can access matrix context at runtime
|
||||
return new DescriptionDictionary();
|
||||
case "matrix": {
|
||||
// Actions can access matrix context at runtime but we don't know the calling workflow's matrix
|
||||
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
|
||||
const matrixContext = new DescriptionDictionary();
|
||||
matrixContext.complete = false;
|
||||
return matrixContext;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "step-uses", undefined));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result?.contents).toEqual(
|
||||
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image."
|
||||
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, a [private repository with access enabled](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository), or in a published Docker container image."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function hover(document: TextDocument, position: Position, config?:
|
||||
// Early exit if there's nothing to provide hover for
|
||||
const hoverToken = token || keyToken;
|
||||
const isExpressionHover =
|
||||
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token));
|
||||
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token, isAction));
|
||||
if (!isExpressionHover && !hoverToken?.definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ export {getInlayHints} from "./inlay-hints.js";
|
||||
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js";
|
||||
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js";
|
||||
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||
export {getCodeActions, CodeActionParams} from "./code-actions/code-actions.js";
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import {isPotentiallyExpression} from "./expression-detection.js";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
||||
import {Definition} from "@actions/workflow-parser/templates/schema/definition";
|
||||
|
||||
// Helper to create a mock TemplateToken with the properties we need to test
|
||||
function createMockToken(options: {value?: string; definitionKey?: string; isString?: boolean}): TemplateToken {
|
||||
const {value = "", definitionKey, isString = true} = options;
|
||||
|
||||
const mockDefinition = definitionKey ? ({key: definitionKey} as Definition) : undefined;
|
||||
|
||||
return {
|
||||
value: isString ? value : undefined,
|
||||
definition: mockDefinition,
|
||||
templateTokenType: isString ? TokenType.String : TokenType.Mapping,
|
||||
// Required by isString type guard (isLiteral checks isLiteral property)
|
||||
isLiteral: isString,
|
||||
isScalar: isString
|
||||
} as unknown as TemplateToken;
|
||||
}
|
||||
|
||||
describe("isPotentiallyExpression", () => {
|
||||
describe("expression markers", () => {
|
||||
it("returns true when token value contains ${{", () => {
|
||||
const token = createMockToken({value: "${{ github.actor }}"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when token value contains embedded ${{", () => {
|
||||
const token = createMockToken({value: "Hello ${{ github.actor }}!"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when token value does not contain ${{", () => {
|
||||
const token = createMockToken({value: "plain text"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-string tokens without expression marker", () => {
|
||||
const token = createMockToken({isString: false});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow schema if-conditions", () => {
|
||||
it("returns true for job-if definition in workflow", () => {
|
||||
const token = createMockToken({value: "success()", definitionKey: "job-if"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for job-if definition in action (not valid in action schema)", () => {
|
||||
const token = createMockToken({value: "success()", definitionKey: "job-if"});
|
||||
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for step-if definition in workflow", () => {
|
||||
const token = createMockToken({value: "failure()", definitionKey: "step-if"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for snapshot-if definition in workflow", () => {
|
||||
const token = createMockToken({value: "always()", definitionKey: "snapshot-if"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for snapshot-if definition in action (not valid in action schema)", () => {
|
||||
const token = createMockToken({value: "always()", definitionKey: "snapshot-if"});
|
||||
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("action schema if-conditions", () => {
|
||||
describe("composite action step if (run and uses)", () => {
|
||||
it("returns true for step-if definition in action", () => {
|
||||
const token = createMockToken({value: "success()", definitionKey: "step-if"});
|
||||
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for step-if with run step condition", () => {
|
||||
// Composite action run step: if condition
|
||||
const token = createMockToken({value: "github.event_name == 'push'", definitionKey: "step-if"});
|
||||
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for step-if with uses step condition", () => {
|
||||
// Composite action uses step: if condition
|
||||
const token = createMockToken({value: "runner.os == 'Linux'", definitionKey: "step-if"});
|
||||
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pre-if and post-if (node/docker actions)", () => {
|
||||
it("returns true for runs-if definition in action (pre-if)", () => {
|
||||
const token = createMockToken({value: "runner.os == 'Linux'", definitionKey: "runs-if"});
|
||||
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for runs-if definition in action (post-if)", () => {
|
||||
const token = createMockToken({value: "always()", definitionKey: "runs-if"});
|
||||
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for runs-if definition in workflow (not valid in workflow schema)", () => {
|
||||
const token = createMockToken({value: "always()", definitionKey: "runs-if"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed scenarios", () => {
|
||||
it("returns true when expression marker present even if definition is not if-related", () => {
|
||||
const token = createMockToken({value: "${{ github.actor }}", definitionKey: "some-other-definition"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when both expression marker and if definition present", () => {
|
||||
const token = createMockToken({value: "${{ success() }}", definitionKey: "step-if"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for plain text with non-if definition", () => {
|
||||
const token = createMockToken({value: "plain text", definitionKey: "string"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when token has no definition and no expression marker", () => {
|
||||
const token = createMockToken({value: "plain text"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty string value", () => {
|
||||
const token = createMockToken({value: ""});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles expression marker as if-condition value", () => {
|
||||
const token = createMockToken({value: "${{ always() }}", definitionKey: "job-if"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||
// For action, job-if is not valid, but ${{ is present
|
||||
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles partial expression marker", () => {
|
||||
const token = createMockToken({value: "${incomplete"});
|
||||
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles ${{ at different positions", () => {
|
||||
const startToken = createMockToken({value: "${{ foo }} bar"});
|
||||
const middleToken = createMockToken({value: "bar ${{ foo }} baz"});
|
||||
const endToken = createMockToken({value: "bar ${{ foo }}"});
|
||||
|
||||
expect(isPotentiallyExpression(startToken, false)).toBe(true);
|
||||
expect(isPotentiallyExpression(middleToken, false)).toBe(true);
|
||||
expect(isPotentiallyExpression(endToken, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,36 @@ import {isString} from "@actions/workflow-parser";
|
||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||
|
||||
export function isPotentiallyExpression(token: TemplateToken): boolean {
|
||||
const containsExpression = isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0;
|
||||
// If conditions are always expressions (job-if, step-if, snapshot-if)
|
||||
const definitionKey = token.definition?.key;
|
||||
const isIfCondition = definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if";
|
||||
return containsExpression || isIfCondition;
|
||||
/**
|
||||
* Workflow schema if-condition definition keys.
|
||||
* - job-if: job level if condition
|
||||
* - step-if: step level if condition
|
||||
* - snapshot-if: snapshot if condition
|
||||
*/
|
||||
const WORKFLOW_IF_DEFINITIONS = new Set(["job-if", "step-if", "snapshot-if"]);
|
||||
|
||||
/**
|
||||
* Action schema if-condition definition keys.
|
||||
* - step-if: composite action step if condition (run-step and uses-step)
|
||||
* - runs-if: pre-if and post-if at the runs level (node/docker actions)
|
||||
*/
|
||||
const ACTION_IF_DEFINITIONS = new Set(["step-if", "runs-if"]);
|
||||
|
||||
export function isPotentiallyExpression(token: TemplateToken, isAction: boolean): boolean {
|
||||
// Check if token contains expression syntax
|
||||
if (isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if token is an if-condition (always treated as expressions)
|
||||
if (!token.definition?.key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Definition keys differ between workflow and action schemas
|
||||
if (isAction) {
|
||||
return ACTION_IF_DEFINITIONS.has(token.definition.key);
|
||||
} else {
|
||||
return WORKFLOW_IF_DEFINITIONS.has(token.definition.key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Shared validation utilities for `if` condition literal text detection.
|
||||
* Used by both workflow and action validation.
|
||||
*/
|
||||
|
||||
import {data} from "@actions/expressions";
|
||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||
|
||||
/**
|
||||
* Checks if a format function contains literal text in its format string.
|
||||
* This indicates user confusion about how expressions work.
|
||||
*
|
||||
* Example: format('push == {0}', github.event_name)
|
||||
* The literal text "push == " will always evaluate to truthy.
|
||||
*
|
||||
* @param expr The expression to check
|
||||
* @returns true if the expression is a format() call with literal text
|
||||
*/
|
||||
export function hasFormatWithLiteralText(expr: Expr): boolean {
|
||||
// If this is a logical AND expression (from ensureStatusFunction wrapping)
|
||||
// check the right side for the format call
|
||||
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
|
||||
return hasFormatWithLiteralText(expr.args[1]);
|
||||
}
|
||||
|
||||
if (!(expr instanceof FunctionCall)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this is a format function
|
||||
if (expr.functionName.lexeme.toLowerCase() !== "format") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the first argument is a string literal
|
||||
if (expr.args.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstArg = expr.args[0];
|
||||
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the format string and trim whitespace
|
||||
const formatString = firstArg.literal.coerceString();
|
||||
const trimmed = formatString.trim();
|
||||
|
||||
// Check if there's literal text (non-replacement tokens) after trimming
|
||||
let inToken = false;
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
if (!inToken && trimmed[i] === "{") {
|
||||
inToken = true;
|
||||
} else if (inToken && trimmed[i] === "}") {
|
||||
inToken = false;
|
||||
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
|
||||
// OK - this is a replacement token like {0}, {1}, etc.
|
||||
} else {
|
||||
// Found literal text
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Shared validation utilities for step `uses` field format.
|
||||
* Used by both workflow and action validation.
|
||||
*/
|
||||
|
||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {mapRange} from "./range.js";
|
||||
|
||||
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
|
||||
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
|
||||
const SHORT_SHA_DOCS_URL =
|
||||
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
|
||||
|
||||
/**
|
||||
* Checks if a ref looks like a short SHA and adds a warning if so.
|
||||
* Returns true if a warning was added.
|
||||
*/
|
||||
export function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
|
||||
if (SHORT_SHA_PATTERN.test(ref)) {
|
||||
diagnostics.push({
|
||||
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: mapRange(token.range),
|
||||
code: "short-sha-ref",
|
||||
codeDescription: {
|
||||
href: SHORT_SHA_DOCS_URL
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the format of a step's `uses` field.
|
||||
*
|
||||
* Valid formats:
|
||||
* - docker://image:tag
|
||||
* - ./local/path
|
||||
* - .\local\path (Windows)
|
||||
* - {owner}/{repo}@{ref}
|
||||
* - {owner}/{repo}/{path}@{ref}
|
||||
*/
|
||||
export function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
const uses = token.value;
|
||||
|
||||
// Empty uses value
|
||||
if (!uses) {
|
||||
diagnostics.push({
|
||||
message: "'uses' value in action cannot be blank",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(token.range),
|
||||
code: "invalid-uses-format"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Docker image reference - always valid format
|
||||
if (uses.startsWith("docker://")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Local action path - always valid format
|
||||
if (uses.startsWith("./") || uses.startsWith(".\\")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote action: must be {owner}/{repo}[/path]@{ref}
|
||||
const atSegments = uses.split("@");
|
||||
|
||||
// Must have exactly one @
|
||||
if (atSegments.length !== 2) {
|
||||
addStepUsesFormatError(diagnostics, token);
|
||||
return;
|
||||
}
|
||||
|
||||
const [repoPath, gitRef] = atSegments;
|
||||
|
||||
// Ref cannot be empty
|
||||
if (!gitRef) {
|
||||
addStepUsesFormatError(diagnostics, token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split by / or \ to get path segments
|
||||
const pathSegments = repoPath.split(/[\\/]/);
|
||||
|
||||
// Must have at least owner and repo (both non-empty)
|
||||
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
|
||||
addStepUsesFormatError(diagnostics, token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a reusable workflow reference (should be at job level, not step)
|
||||
// Path would be like: owner/repo/.github/workflows/file.yml
|
||||
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
|
||||
diagnostics.push({
|
||||
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(token.range),
|
||||
code: "invalid-uses-format"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn if ref looks like a short SHA
|
||||
warnIfShortSha(diagnostics, token, gitRef);
|
||||
}
|
||||
|
||||
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
diagnostics.push({
|
||||
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(token.range),
|
||||
code: "invalid-uses-format"
|
||||
});
|
||||
}
|
||||
@@ -249,7 +249,21 @@ jobs:
|
||||
line: 7
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "missing-required-inputs",
|
||||
data: {
|
||||
action: {
|
||||
name: "cache",
|
||||
owner: "actions",
|
||||
ref: "v1"
|
||||
},
|
||||
missingInputs: [
|
||||
{
|
||||
default: undefined,
|
||||
name: "path"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -294,7 +308,25 @@ jobs:
|
||||
line: 7
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "missing-required-inputs",
|
||||
data: {
|
||||
action: {
|
||||
name: "cache",
|
||||
owner: "actions",
|
||||
ref: "v1"
|
||||
},
|
||||
missingInputs: [
|
||||
{
|
||||
default: undefined,
|
||||
name: "path"
|
||||
},
|
||||
{
|
||||
default: undefined,
|
||||
name: "key"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -323,7 +355,25 @@ jobs:
|
||||
line: 6
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Error
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "missing-required-inputs",
|
||||
data: {
|
||||
action: {
|
||||
name: "cache",
|
||||
owner: "actions",
|
||||
ref: "v1"
|
||||
},
|
||||
missingInputs: [
|
||||
{
|
||||
default: undefined,
|
||||
name: "path"
|
||||
},
|
||||
{
|
||||
default: undefined,
|
||||
name: "key"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -4,10 +4,22 @@ 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 {ActionReference, parseActionReference} from "./action.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {ValidationConfig} from "./validate.js";
|
||||
|
||||
export const DiagnosticCode = {
|
||||
MissingRequiredInputs: "missing-required-inputs"
|
||||
} as const;
|
||||
|
||||
export interface MissingInputsDiagnosticData {
|
||||
action: ActionReference;
|
||||
missingInputs: Array<{
|
||||
name: string;
|
||||
default?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates action references in workflow steps, checking for valid inputs and required inputs.
|
||||
*/
|
||||
@@ -94,10 +106,22 @@ export async function validateActionReference(
|
||||
missingRequiredInputs.length === 1
|
||||
? `Missing required input \`${missingRequiredInputs[0][0]}\``
|
||||
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
|
||||
|
||||
// Build minimal diagnostic data - position calculation happens in the quickfix
|
||||
const diagnosticData: MissingInputsDiagnosticData = {
|
||||
action,
|
||||
missingInputs: missingRequiredInputs.map(([name, input]) => ({
|
||||
name,
|
||||
default: input.default
|
||||
}))
|
||||
};
|
||||
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
|
||||
message: message
|
||||
range: mapRange((withKey || stepToken).range),
|
||||
message: message,
|
||||
code: DiagnosticCode.MissingRequiredInputs,
|
||||
data: diagnosticData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,4 +527,739 @@ runs:
|
||||
expect(diagnostics.some(d => d.message.includes("is not valid for"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composite step uses format validation", () => {
|
||||
it("validates valid uses format with version", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Uses another action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates docker:// uses format", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Uses docker image
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: docker://alpine:3.14
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates local ./ uses format", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Uses local action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: ./local-action
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
|
||||
});
|
||||
|
||||
it("errors on missing @ref", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Missing version
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(true);
|
||||
expect(diagnostics.some(d => d.message.includes("Expected format"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on invalid format", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid format
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: invalid-format
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(true);
|
||||
});
|
||||
|
||||
it("warns on short SHA", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Short SHA
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@a1b2c3d
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "short-sha-ref")).toBe(true);
|
||||
expect(diagnostics.some(d => d.message.includes("shortened commit SHA"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows full SHA", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Full SHA
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "short-sha-ref")).toBe(false);
|
||||
});
|
||||
|
||||
it("errors on reusable workflow in step uses", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Wrong workflow reference
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: owner/repo/.github/workflows/build.yml@main
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Reusable workflows should be referenced"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composite step if literal text validation", () => {
|
||||
it("errors when literal text mixed with embedded expression", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Literal text in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: push == \${{ github.event_name }}
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
|
||||
expect(diagnostics.some(d => d.message.includes("literal text outside replacement tokens"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows valid expression in if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid if expression
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: \${{ github.event_name == 'push' }}
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows if without expression markers (auto-wrapped)", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: If without markers
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: github.event_name == 'push'
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows success() function", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Success function
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: success()
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||
});
|
||||
|
||||
it("errors on format with literal text in if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Format with literal text
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: \${{ format('event is {0}', github.event_name) }}
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows format with only replacement tokens", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Format with only tokens
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: \${{ format('{0}', github.event_name) }}
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||
});
|
||||
|
||||
it("validates if in uses-step", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: If in uses step
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: push == \${{ github.event_name }}
|
||||
uses: actions/checkout@v4
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pre-if and post-if validation", () => {
|
||||
it("errors on explicit expression with literal text in pre-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Literal text in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: push == \${{ github.event_name }}
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
// Explicit ${{ }} syntax is not allowed for pre-if, so we get that error
|
||||
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on explicit expression with literal text in post-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Literal text in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: event == \${{ github.event_name }}
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
// Explicit ${{ }} syntax is not allowed for post-if, so we get that error
|
||||
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on explicit expression with literal text in pre-if for docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Literal text in pre-if
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
pre-entrypoint: /setup.sh
|
||||
pre-if: push == \${{ github.event_name }}
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
// Explicit ${{ }} syntax is not allowed for pre-if, so we get that error
|
||||
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on explicit expression with literal text in post-if for docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Literal text in post-if
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
post-entrypoint: /cleanup.sh
|
||||
post-if: event == \${{ github.event_name }}
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
// Explicit ${{ }} syntax is not allowed for post-if, so we get that error
|
||||
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows valid expression in pre-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: success()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows valid expression in post-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: always()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||
});
|
||||
|
||||
it("errors on explicit expression syntax in pre-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Explicit expression in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: \${{ runner.os == 'Windows' }}
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||
expect(diagnostics.some(d => d.message.includes("pre-if"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on explicit expression syntax in post-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Explicit expression in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: \${{ always() }}
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||
expect(diagnostics.some(d => d.message.includes("post-if"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows expression with failure() in post-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: failure()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows expression with cancelled() in post-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: cancelled()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("format string validation", () => {
|
||||
it("errors on format() with too few arguments in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Format mismatch
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: format('{0} {1}', 'only-one')
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on invalid format string in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Invalid format
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: format('{', 'arg')
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on format() with too few arguments in pre-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Format mismatch in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: format('{0} {1}', 'only-one')
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on format() with too few arguments in post-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Format mismatch in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: format('{0} {1} {2}', 'a', 'b')
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows valid format() call in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid format
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: format('{0} {1}', 'a', 'b') == 'a b'
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(false);
|
||||
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows valid format() call in pre-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid format in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: format('{0}', runner.os) == 'Linux'
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(false);
|
||||
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(false);
|
||||
});
|
||||
|
||||
it("errors on format() with too few arguments in run expression", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Format mismatch in run
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo \${{ format('{0} {1}', 'only-one') }}
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on format() with too few arguments in input default", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Format mismatch in input default
|
||||
inputs:
|
||||
greeting:
|
||||
description: Greeting message
|
||||
default: \${{ format('{0} {1}', 'hello') }}
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("if condition context validation", () => {
|
||||
it("warns on unknown context in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown context in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: foo == bar
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns on unknown context in pre-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown context in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: foo == bar
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns on unknown context in post-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown context in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: foo == bar
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns on unknown context in pre-if for docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown context in pre-if
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
pre-entrypoint: /setup.sh
|
||||
pre-if: foo == bar
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns on unknown context in post-if for docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown context in post-if
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
post-entrypoint: /cleanup.sh
|
||||
post-if: foo == bar
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows valid contexts in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid context in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: github.event_name == 'push'
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows valid contexts in pre-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid context in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: runner.os == 'Linux'
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows valid contexts in post-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid context in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: runner.os == 'Linux'
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows hashFiles function in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: hashFiles in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: hashFiles('**/package-lock.json') != ''
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows success, failure, always, cancelled functions in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Status functions in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: success() && !cancelled()
|
||||
run: echo success
|
||||
shell: bash
|
||||
- if: failure()
|
||||
run: echo failure
|
||||
shell: bash
|
||||
- if: always()
|
||||
run: echo always
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows hashFiles function in pre-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: hashFiles in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: hashFiles('**/package-lock.json') != ''
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows status functions in post-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Status functions in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: always() || failure()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||
});
|
||||
|
||||
it("errors on unknown function in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown function in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: unknownFunc()
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on unknown function in pre-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown function in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: unknownFunc()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on unknown function in post-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown function in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: unknownFunc()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on unknown function in pre-if for docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown function in pre-if
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
pre-entrypoint: /setup.sh
|
||||
pre-if: unknownFunc()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on unknown function in post-if for docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown function in post-if
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
post-entrypoint: /cleanup.sh
|
||||
post-if: unknownFunc()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,20 +2,31 @@
|
||||
* Validation for action.yml / action.yaml manifest files
|
||||
*/
|
||||
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {Lexer, Parser} from "@actions/expressions";
|
||||
import {Expr} from "@actions/expressions/ast";
|
||||
import {isMapping, isString} from "@actions/workflow-parser";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {ActionTemplate} from "@actions/workflow-parser/actions/action-template";
|
||||
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
|
||||
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 {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
||||
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";
|
||||
import {error} from "./log.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {hasFormatWithLiteralText} from "./utils/validate-if.js";
|
||||
import {validateStepUsesFormat} from "./utils/validate-uses.js";
|
||||
import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
|
||||
import {validateActionReference} from "./validate-action-reference.js";
|
||||
import {validateFormatCalls} from "./validate-format-string.js";
|
||||
import {ValidationConfig} from "./validate.js";
|
||||
|
||||
/**
|
||||
@@ -65,7 +76,15 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get schema errors
|
||||
// Convert the action template (this may add validation errors for pre-if/post-if)
|
||||
let template: ActionTemplate | undefined;
|
||||
if (result.value) {
|
||||
template = getOrConvertActionTemplate(result.context, result.value, textDocument.uri, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
}
|
||||
|
||||
// Get schema and conversion errors (must be after conversion to include conversion errors)
|
||||
const schemaErrors = result.context.errors.getErrors();
|
||||
|
||||
// Run custom runs key validation, which also filters redundant schema errors in place
|
||||
@@ -93,13 +112,9 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
||||
}
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
if (result.value && template) {
|
||||
// Only composite actions have steps to validate
|
||||
if (template?.runs?.using === "composite") {
|
||||
if (template.runs?.using === "composite") {
|
||||
const steps = template.runs.steps ?? [];
|
||||
|
||||
// Find the steps sequence token from the raw parsed result
|
||||
@@ -114,9 +129,17 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
||||
if (isActionStep(step) && isMapping(stepToken)) {
|
||||
await validateActionReference(diagnostics, stepToken, step, config);
|
||||
}
|
||||
|
||||
// Validate step uses format
|
||||
if (isMapping(stepToken)) {
|
||||
validateStepUsesField(diagnostics, stepToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single traversal for all expression validation (like workflow's additionalValidations)
|
||||
validateAllTokens(diagnostics, result.value);
|
||||
}
|
||||
} catch (e) {
|
||||
error(`Unhandled error while validating action file: ${(e as Error).message}`);
|
||||
@@ -125,6 +148,196 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the `uses` field format in a composite action step.
|
||||
*/
|
||||
function validateStepUsesField(diagnostics: Diagnostic[], stepToken: MappingToken): void {
|
||||
for (let i = 0; i < stepToken.count; i++) {
|
||||
const {key, value} = stepToken.get(i);
|
||||
const keyStr = isString(key) ? key.value.toLowerCase() : "";
|
||||
|
||||
if (keyStr === "uses" && isString(value)) {
|
||||
validateStepUsesFormat(diagnostics, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single traversal validation for all tokens in the action template.
|
||||
* This follows the same pattern as workflow validation's additionalValidations:
|
||||
* - For BasicExpressionToken: validate format() calls
|
||||
* - For StringToken on if conditions: validate literal text detection and format() calls
|
||||
* - For pre-if/post-if with explicit ${{ }}: report error (not supported by runner)
|
||||
*
|
||||
* Context validation (unknown named values) is handled by workflow-parser during conversion.
|
||||
*/
|
||||
function validateAllTokens(diagnostics: Diagnostic[], root: TemplateToken): void {
|
||||
for (const [parent, token] of TemplateToken.traverse(root)) {
|
||||
const definitionKey = token.definition?.key;
|
||||
|
||||
// Validate all BasicExpressionToken instances for format() calls
|
||||
if (token instanceof BasicExpressionToken && token.range) {
|
||||
// Check for literal text in if conditions (format with literal text)
|
||||
if (definitionKey === "step-if") {
|
||||
validateIfLiteralText(diagnostics, token);
|
||||
}
|
||||
|
||||
// Validate format() calls for all expressions
|
||||
for (const expression of token.originalExpressions || [token]) {
|
||||
validateExpressionFormatCalls(diagnostics, expression);
|
||||
}
|
||||
|
||||
// Check for explicit ${{ }} in pre-if/post-if (not supported by runner)
|
||||
if (definitionKey === "runs-if" && parent instanceof MappingToken) {
|
||||
// Resolve the key name (pre-if or post-if) from parent mapping
|
||||
let keyName: string | undefined;
|
||||
for (let i = 0; i < parent.count; i++) {
|
||||
const {key, value} = parent.get(i);
|
||||
if (value === token) {
|
||||
keyName = key.toString().toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyName) {
|
||||
diagnostics.push({
|
||||
message: `Explicit expression syntax \${{ }} is not supported for '${keyName}'. Remove the \${{ }} markers and use the expression directly.`,
|
||||
range: mapRange(token.range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "explicit-expression-not-allowed"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle implicit if conditions (StringToken without ${{ }})
|
||||
// These allow expression syntax without the markers
|
||||
if (isString(token) && token.range) {
|
||||
if (definitionKey === "step-if" || definitionKey === "runs-if") {
|
||||
validateImplicitIfCondition(diagnostics, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LITERAL_TEXT_IN_CONDITION_MESSAGE =
|
||||
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?";
|
||||
const LITERAL_TEXT_IN_CONDITION_CODE = "expression-literal-text-in-condition";
|
||||
|
||||
/**
|
||||
* Validates an implicit if condition (StringToken without ${{ }}).
|
||||
* Checks for literal text detection and validates format() calls.
|
||||
*/
|
||||
function validateImplicitIfCondition(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
const condition = token.value.trim();
|
||||
if (!condition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||
|
||||
// Ensure the condition has a status function, wrapping if needed
|
||||
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
|
||||
|
||||
try {
|
||||
const l = new Lexer(finalCondition);
|
||||
const lr = l.lex();
|
||||
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||
const expr = p.parse();
|
||||
|
||||
// Check for literal text in the expression (format with literal text)
|
||||
if (hasFormatWithLiteralText(expr)) {
|
||||
diagnostics.push({
|
||||
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
|
||||
range: mapRange(token.range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: LITERAL_TEXT_IN_CONDITION_CODE
|
||||
});
|
||||
}
|
||||
|
||||
// Validate format() function calls
|
||||
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
|
||||
} catch {
|
||||
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a BasicExpressionToken for literal text in if conditions.
|
||||
*/
|
||||
function validateIfLiteralText(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||
|
||||
try {
|
||||
const l = new Lexer(token.expression);
|
||||
const lr = l.lex();
|
||||
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||
const expr = p.parse();
|
||||
|
||||
if (hasFormatWithLiteralText(expr)) {
|
||||
diagnostics.push({
|
||||
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
|
||||
range: mapRange(token.range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: LITERAL_TEXT_IN_CONDITION_CODE
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates format() function calls in an expression token.
|
||||
*/
|
||||
function validateExpressionFormatCalls(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||
|
||||
try {
|
||||
const l = new Lexer(token.expression);
|
||||
const lr = l.lex();
|
||||
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||
const expr = p.parse();
|
||||
|
||||
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
|
||||
} catch {
|
||||
// Ignore parse errors - they'll be caught by schema validation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to validate format() function calls and add diagnostics.
|
||||
*/
|
||||
function validateFormatCallsAndAddDiagnostics(
|
||||
diagnostics: Diagnostic[],
|
||||
expr: Expr,
|
||||
range: TokenRange | undefined
|
||||
): void {
|
||||
const formatErrors = validateFormatCalls(expr);
|
||||
for (const formatError of formatErrors) {
|
||||
if (formatError.type === "invalid-syntax") {
|
||||
diagnostics.push({
|
||||
message: `Invalid format string: ${formatError.message}`,
|
||||
range: mapRange(range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "invalid-format-string"
|
||||
});
|
||||
} else if (formatError.type === "arg-count-mismatch") {
|
||||
diagnostics.push({
|
||||
message: `Format string references argument {${formatError.expected - 1}} but only ${
|
||||
formatError.provided
|
||||
} argument(s) provided`,
|
||||
range: mapRange(range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "format-arg-count-mismatch"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the steps sequence token from the raw action template.
|
||||
* Traverses the token tree looking for the "composite-steps" definition.
|
||||
|
||||
@@ -160,6 +160,21 @@ jobs:
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("errors on unknown context in plain string if condition", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: foo == bar
|
||||
run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshot-if", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {FeatureFlags, Lexer, Parser, data} from "@actions/expressions";
|
||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
|
||||
import {Expr} from "@actions/expressions/ast";
|
||||
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";
|
||||
@@ -24,6 +24,8 @@ 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 {hasFormatWithLiteralText} from "./utils/validate-if.js";
|
||||
import {validateStepUsesFormat, warnIfShortSha} from "./utils/validate-uses.js";
|
||||
import {getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {validateActionReference} from "./validate-action-reference.js";
|
||||
import {validateAction} from "./validate-action.js";
|
||||
@@ -285,116 +287,6 @@ function validateCronExpression(diagnostics: Diagnostic[], token: StringToken):
|
||||
}
|
||||
}
|
||||
|
||||
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
|
||||
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
|
||||
const SHORT_SHA_DOCS_URL =
|
||||
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
|
||||
|
||||
/**
|
||||
* Checks if a ref looks like a short SHA and adds a warning if so.
|
||||
* Returns true if a warning was added.
|
||||
*/
|
||||
function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
|
||||
if (SHORT_SHA_PATTERN.test(ref)) {
|
||||
diagnostics.push({
|
||||
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: mapRange(token.range),
|
||||
code: "short-sha-ref",
|
||||
codeDescription: {
|
||||
href: SHORT_SHA_DOCS_URL
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the format of a step's `uses` field.
|
||||
*
|
||||
* Valid formats:
|
||||
* - docker://image:tag
|
||||
* - ./local/path
|
||||
* - .\local\path (Windows)
|
||||
* - {owner}/{repo}@{ref}
|
||||
* - {owner}/{repo}/{path}@{ref}
|
||||
*/
|
||||
function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
const uses = token.value;
|
||||
|
||||
// Empty uses value
|
||||
if (!uses) {
|
||||
diagnostics.push({
|
||||
message: "`uses' value in action cannot be blank",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(token.range),
|
||||
code: "invalid-uses-format"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Docker image reference - always valid format
|
||||
if (uses.startsWith("docker://")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Local action path - always valid format
|
||||
if (uses.startsWith("./") || uses.startsWith(".\\")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote action: must be {owner}/{repo}[/path]@{ref}
|
||||
const atSegments = uses.split("@");
|
||||
|
||||
// Must have exactly one @
|
||||
if (atSegments.length !== 2) {
|
||||
addStepUsesFormatError(diagnostics, token);
|
||||
return;
|
||||
}
|
||||
|
||||
const [repoPath, gitRef] = atSegments;
|
||||
|
||||
// Ref cannot be empty
|
||||
if (!gitRef) {
|
||||
addStepUsesFormatError(diagnostics, token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split by / or \ to get path segments
|
||||
const pathSegments = repoPath.split(/[\\/]/);
|
||||
|
||||
// Must have at least owner and repo (both non-empty)
|
||||
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
|
||||
addStepUsesFormatError(diagnostics, token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a reusable workflow reference (should be at job level, not step)
|
||||
// Path would be like: owner/repo/.github/workflows/file.yml
|
||||
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
|
||||
diagnostics.push({
|
||||
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(token.range),
|
||||
code: "invalid-uses-format"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Warn if ref looks like a short SHA
|
||||
warnIfShortSha(diagnostics, token, gitRef);
|
||||
}
|
||||
|
||||
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
diagnostics.push({
|
||||
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(token.range),
|
||||
code: "invalid-uses-format"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the format of a job's `uses` field (reusable workflow reference).
|
||||
*
|
||||
@@ -639,64 +531,6 @@ function getProviderContext(
|
||||
return getWorkflowContext(documentUri, template, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a format function contains literal text in its format string.
|
||||
* This indicates user confusion about how expressions work.
|
||||
*
|
||||
* Example: format('push == {0}', github.event_name)
|
||||
* The literal text "push == " will always evaluate to truthy.
|
||||
*
|
||||
* @param expr The expression to check
|
||||
* @returns true if the expression is a format() call with literal text
|
||||
*/
|
||||
function hasFormatWithLiteralText(expr: Expr): boolean {
|
||||
// If this is a logical AND expression (from ensureStatusFunction wrapping)
|
||||
// check the right side for the format call
|
||||
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
|
||||
return hasFormatWithLiteralText(expr.args[1]);
|
||||
}
|
||||
|
||||
if (!(expr instanceof FunctionCall)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this is a format function
|
||||
if (expr.functionName.lexeme.toLowerCase() !== "format") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the first argument is a string literal
|
||||
if (expr.args.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstArg = expr.args[0];
|
||||
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the format string and trim whitespace
|
||||
const formatString = firstArg.literal.coerceString();
|
||||
const trimmed = formatString.trim();
|
||||
|
||||
// Check if there's literal text (non-replacement tokens) after trimming
|
||||
let inToken = false;
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
if (!inToken && trimmed[i] === "{") {
|
||||
inToken = true;
|
||||
} else if (inToken && trimmed[i] === "}") {
|
||||
inToken = false;
|
||||
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
|
||||
// OK - this is a replacement token like {0}, {1}, etc.
|
||||
} else {
|
||||
// Found literal text
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function validateExpression(
|
||||
diagnostics: Diagnostic[],
|
||||
token: BasicExpressionToken,
|
||||
|
||||
@@ -295,7 +295,7 @@ jobs:
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toContainEqual({
|
||||
message: "`uses' value in action cannot be blank",
|
||||
message: "'uses' value in action cannot be blank",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 5, character: 12},
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.35"
|
||||
"version": "0.3.44"
|
||||
}
|
||||
Generated
+9
-9
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"expressions": {
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.35",
|
||||
"version": "0.3.44",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -396,11 +396,11 @@
|
||||
},
|
||||
"languageserver": {
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.35",
|
||||
"version": "0.3.44",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.35",
|
||||
"@actions/workflow-parser": "^0.3.35",
|
||||
"@actions/languageservice": "^0.3.44",
|
||||
"@actions/workflow-parser": "^0.3.44",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -940,11 +940,11 @@
|
||||
},
|
||||
"languageservice": {
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.35",
|
||||
"version": "0.3.44",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.35",
|
||||
"@actions/workflow-parser": "^0.3.35",
|
||||
"@actions/expressions": "^0.3.44",
|
||||
"@actions/workflow-parser": "^0.3.44",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
@@ -13345,10 +13345,10 @@
|
||||
},
|
||||
"workflow-parser": {
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.35",
|
||||
"version": "0.3.44",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.35",
|
||||
"@actions/expressions": "^0.3.44",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.35",
|
||||
"version": "0.3.44",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -48,7 +48,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.35",
|
||||
"@actions/expressions": "^0.3.44",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -137,6 +137,24 @@
|
||||
],
|
||||
"string": {}
|
||||
},
|
||||
"runs-if": {
|
||||
"description": "Condition to control when this action's pre or post script runs.",
|
||||
"context": [
|
||||
"runner",
|
||||
"github",
|
||||
"job",
|
||||
"strategy",
|
||||
"matrix",
|
||||
"env",
|
||||
"inputs",
|
||||
"always(0,0)",
|
||||
"success(0,0)",
|
||||
"failure(0,0)",
|
||||
"cancelled(0,0)",
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"string": {}
|
||||
},
|
||||
"runs": {
|
||||
"one-of": [
|
||||
"container-runs",
|
||||
@@ -242,7 +260,7 @@
|
||||
"description": "Allows you to run a script before the entrypoint action begins.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-entrypoint)"
|
||||
},
|
||||
"pre-if": {
|
||||
"type": "non-empty-string",
|
||||
"type": "runs-if",
|
||||
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
|
||||
},
|
||||
"post-entrypoint": {
|
||||
@@ -250,7 +268,7 @@
|
||||
"description": "Allows you to run a cleanup script once the runs.entrypoint action has completed.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-entrypoint)"
|
||||
},
|
||||
"post-if": {
|
||||
"type": "non-empty-string",
|
||||
"type": "runs-if",
|
||||
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
|
||||
}
|
||||
}
|
||||
@@ -275,7 +293,7 @@
|
||||
"description": "Allows you to run a script at the start of a job, before the main: action begins. You can use pre: to run prerequisite setup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre)"
|
||||
},
|
||||
"pre-if": {
|
||||
"type": "non-empty-string",
|
||||
"type": "runs-if",
|
||||
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
|
||||
},
|
||||
"post": {
|
||||
@@ -283,7 +301,7 @@
|
||||
"description": "Allows you to run a script at the end of a job, once the main: action has completed. You can use post: to run cleanup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost)"
|
||||
},
|
||||
"post-if": {
|
||||
"type": "non-empty-string",
|
||||
"type": "runs-if",
|
||||
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,4 +317,53 @@ runs:
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reports error for invalid context in pre-if", () => {
|
||||
const content = `
|
||||
name: Node Action
|
||||
description: A node action
|
||||
runs:
|
||||
using: node20
|
||||
main: dist/index.js
|
||||
pre: dist/setup.js
|
||||
pre-if: foo == bar`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
expect(result.value).toBeDefined();
|
||||
if (!result.value) return;
|
||||
|
||||
// Should have no errors before conversion
|
||||
expect(result.context.errors.count).toBe(0);
|
||||
|
||||
// Convert the template - this should add the validation error
|
||||
convertActionTemplate(result.context, result.value);
|
||||
|
||||
// Should have an error now about invalid context
|
||||
expect(result.context.errors.count).toBeGreaterThan(0);
|
||||
const errors = result.context.errors.getErrors();
|
||||
expect(errors.some(e => e.rawMessage.includes("foo"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid context in pre-if", () => {
|
||||
const content = `
|
||||
name: Node Action
|
||||
description: A node action
|
||||
runs:
|
||||
using: node20
|
||||
main: dist/index.js
|
||||
pre: dist/setup.js
|
||||
pre-if: runner.os == 'Linux'`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
expect(result.value).toBeDefined();
|
||||
if (!result.value) return;
|
||||
|
||||
const template = convertActionTemplate(result.context, result.value);
|
||||
|
||||
// Should have no errors
|
||||
expect(result.context.errors.count).toBe(0);
|
||||
if (template.runs.using === "node20") {
|
||||
expect(template.runs.preIf).toBe("runner.os == 'Linux'");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {TemplateContext} from "../templates/template-context.js";
|
||||
import {isBoolean, isMapping, isScalar, isSequence, isString} from "../templates/tokens/type-guards.js";
|
||||
import {ErrorPolicy} from "../model/convert.js";
|
||||
import {Step} from "../model/workflow-template.js";
|
||||
import {convertToIfCondition} from "../model/converter/if-condition.js";
|
||||
import {convertToIfCondition, validateRunsIfCondition} from "../model/converter/if-condition.js";
|
||||
|
||||
/**
|
||||
* Represents a parsed and converted action.yml file
|
||||
@@ -310,7 +310,7 @@ function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns
|
||||
|
||||
case "pre-if":
|
||||
if (isString(item.value)) {
|
||||
preIf = item.value.value;
|
||||
preIf = validateRunsIfCondition(context, item.value, item.value.value);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -322,7 +322,7 @@ function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns
|
||||
|
||||
case "post-if":
|
||||
if (isString(item.value)) {
|
||||
postIf = item.value.value;
|
||||
postIf = validateRunsIfCondition(context, item.value, item.value.value);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {TemplateContext} from "../templates/template-context.js";
|
||||
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
|
||||
import {FileProvider} from "../workflows/file-provider.js";
|
||||
@@ -37,9 +38,15 @@ export type WorkflowTemplateConverterOptions = {
|
||||
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
|
||||
*/
|
||||
errorPolicy?: ErrorPolicy;
|
||||
|
||||
/**
|
||||
* Feature flags for experimental features.
|
||||
* When not provided, all experimental features are disabled.
|
||||
*/
|
||||
featureFlags?: FeatureFlags;
|
||||
};
|
||||
|
||||
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
|
||||
const defaultOptions: Omit<Required<WorkflowTemplateConverterOptions>, "featureFlags"> = {
|
||||
maxReusableWorkflowDepth: 4,
|
||||
fetchReusableWorkflowDepth: 0,
|
||||
errorPolicy: ErrorPolicy.ReturnErrorsOnly
|
||||
@@ -54,6 +61,11 @@ export async function convertWorkflowTemplate(
|
||||
const result = {} as WorkflowTemplate;
|
||||
const opts = getOptionsWithDefaults(options);
|
||||
|
||||
// Store feature flags in context for converter functions
|
||||
if (options.featureFlags) {
|
||||
context.state["featureFlags"] = options.featureFlags;
|
||||
}
|
||||
|
||||
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
||||
result.errors = context.errors.getErrors().map(x => ({
|
||||
Message: x.message
|
||||
@@ -132,7 +144,9 @@ export async function convertWorkflowTemplate(
|
||||
return result;
|
||||
}
|
||||
|
||||
function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Required<WorkflowTemplateConverterOptions> {
|
||||
function getOptionsWithDefaults(
|
||||
options: WorkflowTemplateConverterOptions
|
||||
): Omit<Required<WorkflowTemplateConverterOptions>, "featureFlags"> {
|
||||
return {
|
||||
maxReusableWorkflowDepth:
|
||||
options.maxReusableWorkflowDepth !== undefined
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {nullTrace} from "../../test-utils/null-trace.js";
|
||||
import {parseWorkflow} from "../../workflows/workflow-parser.js";
|
||||
import {convertWorkflowTemplate, ErrorPolicy} from "../convert.js";
|
||||
|
||||
// Minimal FeatureFlags-compatible object for tests
|
||||
const featureFlags = {isEnabled: (f: string) => f === "containerImageValidation"};
|
||||
|
||||
async function getErrors(content: string): Promise<string[]> {
|
||||
const result = parseWorkflow({name: "wf.yaml", content}, nullTrace);
|
||||
result.context.state["featureFlags"] = featureFlags;
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
return (template.errors ?? []).map((e: {Message: string}) => e.Message);
|
||||
}
|
||||
|
||||
function expectNoContainerErrors(errors: string[]): void {
|
||||
const containerErrors = errors.filter(e => e.includes("Container image"));
|
||||
expect(containerErrors).toHaveLength(0);
|
||||
}
|
||||
|
||||
function expectContainerError(errors: string[], count = 1): void {
|
||||
const containerErrors = errors.filter(e => e.includes("Container image cannot be empty"));
|
||||
expect(containerErrors).toHaveLength(count);
|
||||
}
|
||||
|
||||
describe("container image validation", () => {
|
||||
describe("shorthand form", () => {
|
||||
it("container: '' is silent for job container", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container: ''
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("container: docker:// errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container: docker://
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("container: valid-image passes", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container: ubuntu:16.04
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapping form", () => {
|
||||
it("container image: '' errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: ''
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("container image: docker:// errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: docker://
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("container: {} (empty object, missing image) errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container: {}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("container image: null errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image:
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("empty image with expression in other field still errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: ''
|
||||
options: \${{ matrix.opts }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
});
|
||||
|
||||
describe("services shorthand", () => {
|
||||
it("services svc: '' errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc: ''
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("services svc: docker:// errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc: docker://
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
});
|
||||
|
||||
describe("services mapping", () => {
|
||||
it("services svc image: '' errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc:
|
||||
image: ''
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("services svc image: docker:// errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc:
|
||||
image: docker://
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("services svc: {} (empty object) errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc: {}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("empty image with expression sibling service still errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc1:
|
||||
image: ''
|
||||
svc2: \${{ matrix.svc }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expression safety", () => {
|
||||
it("container: expression skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container: \${{ matrix.container }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("container image: expression skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: \${{ matrix.image }}
|
||||
options: --privileged
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("container with expression key skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
\${{ vars.KEY }}: ubuntu
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("services: expression skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services: \${{ fromJSON(inputs.services) }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("services with expression alias key skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
\${{ matrix.alias }}: postgres
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("services container with expression key skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
db:
|
||||
\${{ vars.KEY }}: postgres
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("container with all expression fields skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: \${{ matrix.image }}
|
||||
options: \${{ matrix.options }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("services svc: expression skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
db: \${{ matrix.db }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("services image: expression skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
db:
|
||||
image: \${{ matrix.db_image }}
|
||||
options: --health-cmd pg_isready
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,199 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {TemplateContext} from "../../templates/template-context.js";
|
||||
import {MappingToken, SequenceToken, StringToken, TemplateToken} from "../../templates/tokens/index.js";
|
||||
import {isString} from "../../templates/tokens/type-guards.js";
|
||||
import {Container, Credential} from "../workflow-template.js";
|
||||
|
||||
export function convertToJobContainer(context: TemplateContext, container: TemplateToken): Container | undefined {
|
||||
function getFeatureFlags(context: TemplateContext): FeatureFlags | undefined {
|
||||
return context.state["featureFlags"] as FeatureFlags | undefined;
|
||||
}
|
||||
|
||||
const DOCKER_URI_PREFIX = "docker://";
|
||||
|
||||
function isEmptyImage(value: string): boolean {
|
||||
const trimmed = value.startsWith(DOCKER_URI_PREFIX) ? value.substring(DOCKER_URI_PREFIX.length) : value;
|
||||
return trimmed.length === 0;
|
||||
}
|
||||
|
||||
export function convertToJobContainer(
|
||||
context: TemplateContext,
|
||||
container: TemplateToken,
|
||||
isServiceContainer = false
|
||||
): Container | undefined {
|
||||
// Feature flag guard — use legacy implementation when flag is off
|
||||
if (!getFeatureFlags(context)?.isEnabled("containerImageValidation")) {
|
||||
return convertToJobContainerLegacy(context, container);
|
||||
}
|
||||
|
||||
if (container.isExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Shorthand form
|
||||
if (isString(container)) {
|
||||
const image = container.assertString("container item");
|
||||
if (!image || image.value.length === 0) {
|
||||
if (isServiceContainer) {
|
||||
context.error(container, "Container image cannot be empty");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmptyImage(image.value)) {
|
||||
context.error(container, "Container image cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
return {image};
|
||||
}
|
||||
|
||||
// Mapping form
|
||||
const mapping = container.assertMapping("container item");
|
||||
if (!mapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
let image: StringToken | undefined;
|
||||
let env: MappingToken | undefined;
|
||||
let ports: SequenceToken | undefined;
|
||||
let volumes: SequenceToken | undefined;
|
||||
let options: StringToken | undefined;
|
||||
let credentials: Credential | undefined;
|
||||
let hasExpressionKey = false;
|
||||
let hasExpression = false;
|
||||
|
||||
for (const item of mapping) {
|
||||
if (item.key.isExpression) {
|
||||
hasExpressionKey = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = item.key.assertString("container item key");
|
||||
|
||||
switch (key.value) {
|
||||
case "image":
|
||||
if (item.value.isExpression) {
|
||||
hasExpression = true;
|
||||
break;
|
||||
}
|
||||
image = item.value.assertString("container image");
|
||||
break;
|
||||
case "credentials":
|
||||
if (!item.value.isExpression) {
|
||||
credentials = convertCredentials(context, item.value);
|
||||
}
|
||||
break;
|
||||
case "env":
|
||||
if (!item.value.isExpression) {
|
||||
env = item.value.assertMapping("container env");
|
||||
}
|
||||
break;
|
||||
case "ports":
|
||||
if (!item.value.isExpression) {
|
||||
ports = item.value.assertSequence("container ports");
|
||||
}
|
||||
break;
|
||||
case "volumes":
|
||||
if (!item.value.isExpression) {
|
||||
volumes = item.value.assertSequence("container volumes");
|
||||
}
|
||||
break;
|
||||
case "options":
|
||||
if (!item.value.isExpression) {
|
||||
options = item.value.assertString("container options");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
context.error(key, `Unexpected container item key: ${key.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate image
|
||||
if (image) {
|
||||
if (isEmptyImage(image.value)) {
|
||||
context.error(image, "Container image cannot be empty");
|
||||
return;
|
||||
}
|
||||
return {image, credentials, env, ports, volumes, options};
|
||||
}
|
||||
|
||||
// No image key — skip error if expression keys could provide one
|
||||
if (!hasExpressionKey && !hasExpression) {
|
||||
context.error(container, "Container image cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
||||
// Feature flag guard — use legacy implementation when flag is off
|
||||
if (!getFeatureFlags(context)?.isEnabled("containerImageValidation")) {
|
||||
return convertToJobServicesLegacy(context, services);
|
||||
}
|
||||
|
||||
if (services.isExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceList: Container[] = [];
|
||||
const mapping = services.assertMapping("services");
|
||||
|
||||
for (const service of mapping) {
|
||||
if (service.key.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
service.key.assertString("service key");
|
||||
const container = convertToJobContainer(context, service.value, true);
|
||||
if (container) {
|
||||
serviceList.push(container);
|
||||
}
|
||||
}
|
||||
|
||||
return serviceList;
|
||||
}
|
||||
|
||||
function convertCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
|
||||
const mapping = value.assertMapping("credentials");
|
||||
if (!mapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
let username: StringToken | undefined;
|
||||
let password: StringToken | undefined;
|
||||
|
||||
for (const item of mapping) {
|
||||
if (item.key.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = item.key.assertString("credentials item");
|
||||
if (item.value.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (key.value) {
|
||||
case "username":
|
||||
username = item.value.assertString("credentials username");
|
||||
break;
|
||||
case "password":
|
||||
password = item.value.assertString("credentials password");
|
||||
break;
|
||||
default:
|
||||
context.error(key, `credentials key ${key.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {username, password};
|
||||
}
|
||||
|
||||
// ===== Legacy implementations (remove when containerImageValidation graduates) =====
|
||||
|
||||
function convertToJobContainerLegacy(context: TemplateContext, container: TemplateToken): Container | undefined {
|
||||
let image: StringToken | undefined;
|
||||
let env: MappingToken | undefined;
|
||||
let ports: SequenceToken | undefined;
|
||||
let volumes: SequenceToken | undefined;
|
||||
let options: StringToken | undefined;
|
||||
|
||||
// Skip validation for expressions for now to match
|
||||
// behavior of the other parsers
|
||||
for (const [, token] of TemplateToken.traverse(container)) {
|
||||
if (token.isExpression) {
|
||||
return;
|
||||
@@ -19,7 +201,6 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
|
||||
}
|
||||
|
||||
if (isString(container)) {
|
||||
// Workflow uses shorthand syntax `container: image-name`
|
||||
image = container.assertString("container item");
|
||||
return {image: image};
|
||||
}
|
||||
@@ -35,7 +216,7 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
|
||||
image = value.assertString("container image");
|
||||
break;
|
||||
case "credentials":
|
||||
convertToJobCredentials(context, value);
|
||||
convertToJobCredentialsLegacy(context, value);
|
||||
break;
|
||||
case "env":
|
||||
env = value.assertMapping("container env");
|
||||
@@ -70,13 +251,13 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
||||
function convertToJobServicesLegacy(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
||||
const serviceList: Container[] = [];
|
||||
|
||||
const mapping = services.assertMapping("services");
|
||||
for (const service of mapping) {
|
||||
service.key.assertString("service key");
|
||||
const container = convertToJobContainer(context, service.value);
|
||||
const container = convertToJobContainerLegacy(context, service.value);
|
||||
if (container) {
|
||||
serviceList.push(container);
|
||||
}
|
||||
@@ -84,7 +265,7 @@ export function convertToJobServices(context: TemplateContext, services: Templat
|
||||
return serviceList;
|
||||
}
|
||||
|
||||
function convertToJobCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
|
||||
function convertToJobCredentialsLegacy(context: TemplateContext, value: TemplateToken): Credential | undefined {
|
||||
const mapping = value.assertMapping("credentials");
|
||||
|
||||
let username: StringToken | undefined;
|
||||
|
||||
@@ -136,3 +136,32 @@ function walkTreeToFindStatusFunctionCalls(tree: Expr | undefined): boolean {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a pre-if or post-if condition string.
|
||||
* Unlike step if conditions, pre-if and post-if are evaluated as-is by the runner
|
||||
* (they default to always() only when the field is missing entirely).
|
||||
* This function validates the expression and reports errors through the context.
|
||||
*
|
||||
* @param context The template context for error reporting
|
||||
* @param token The token containing the condition
|
||||
* @param condition The condition string to validate
|
||||
* @returns The validated condition string, or undefined on error
|
||||
*/
|
||||
export function validateRunsIfCondition(
|
||||
context: TemplateContext,
|
||||
token: TemplateToken,
|
||||
condition: string
|
||||
): string | undefined {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
|
||||
// Validate the expression directly - no wrapping needed for pre-if/post-if
|
||||
try {
|
||||
ExpressionToken.validateExpression(condition, allowedContext);
|
||||
} catch (err) {
|
||||
context.error(token, err as Error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return condition;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
|
||||
break;
|
||||
|
||||
case "container":
|
||||
convertToJobContainer(context, item.value);
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertToJobContainer(context, item.value));
|
||||
container = item.value;
|
||||
break;
|
||||
|
||||
@@ -103,7 +103,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
|
||||
break;
|
||||
|
||||
case "services":
|
||||
convertToJobServices(context, item.value);
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertToJobServices(context, item.value));
|
||||
services = item.value;
|
||||
break;
|
||||
|
||||
|
||||
@@ -2172,7 +2172,7 @@
|
||||
}
|
||||
},
|
||||
"step-uses": {
|
||||
"description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image.",
|
||||
"description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, a [private repository with access enabled](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository), or in a published Docker container image.",
|
||||
"string": {
|
||||
"require-non-empty": true
|
||||
}
|
||||
@@ -2345,11 +2345,11 @@
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "non-empty-string",
|
||||
"type": "string",
|
||||
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
|
||||
},
|
||||
"options": {
|
||||
"type": "non-empty-string",
|
||||
"type": "string",
|
||||
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
|
||||
},
|
||||
"env": "container-env",
|
||||
@@ -2390,7 +2390,7 @@
|
||||
"matrix"
|
||||
],
|
||||
"one-of": [
|
||||
"non-empty-string",
|
||||
"string",
|
||||
"container-mapping"
|
||||
]
|
||||
},
|
||||
|
||||
+1
@@ -91,3 +91,4 @@ yaml-schema-sequence.yml
|
||||
yaml-schema-str-flow-styles.yml
|
||||
yaml-schema-string.yml
|
||||
yaml-schema-timestamp.yml
|
||||
job-container-invalid.yml
|
||||
|
||||
Reference in New Issue
Block a user