Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9f6ffbb5d |
@@ -1,4 +1 @@
|
|||||||
* @actions/actions-vscode-reviewers
|
* @actions/actions-vscode-reviewers
|
||||||
|
|
||||||
# Owners maintaining https://github.com/actions/runner-images
|
|
||||||
/languageservice/src/value-providers/default.ts @actions/runner-images-writers @actions/actions-vscode-reviewers
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [20.x, 22.x, 24.x]
|
node-version: [18.x, 20.x, 22.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -37,10 +37,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 24.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 22.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: "16"
|
||||||
|
|
||||||
- name: Bump version and push
|
- name: Bump version and push
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
packages: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PKG_VERSION: "" # will be set in the workflow
|
PKG_VERSION: "" # will be set in the workflow
|
||||||
@@ -69,8 +69,9 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 22.x
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
scope: '@actions'
|
||||||
|
|
||||||
- name: Parse version from lerna.json
|
- name: Parse version from lerna.json
|
||||||
run: |
|
run: |
|
||||||
@@ -96,6 +97,13 @@ jobs:
|
|||||||
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
|
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
|
||||||
await core.summary.write();
|
await core.summary.write();
|
||||||
|
|
||||||
|
- name: setup authentication
|
||||||
|
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
- name: Publish packages
|
- name: Publish packages
|
||||||
run: |
|
run: |
|
||||||
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
@@ -4,4 +4,3 @@ dist
|
|||||||
*.js
|
*.js
|
||||||
*.json
|
*.json
|
||||||
*.d.ts
|
*.d.ts
|
||||||
/.nx/workspace-data
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Agents
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
```
|
|
||||||
npx lerna run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test
|
|
||||||
|
|
||||||
```
|
|
||||||
npm -w @actions/expressions test
|
|
||||||
npm -w @actions/workflow-parser test
|
|
||||||
npm -w @actions/languageservice test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Format
|
|
||||||
|
|
||||||
Always run formatting before committing:
|
|
||||||
|
|
||||||
```
|
|
||||||
npx prettier --write <changed files>
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify with:
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run format-check -ws
|
|
||||||
```
|
|
||||||
|
|
||||||
## Feature flags
|
|
||||||
|
|
||||||
Feature flags are defined in `expressions/src/features.ts` (`ExperimentalFeatures` interface + `allFeatureKeys` array). They are plumbed through `ConvertOptions`, `CompletionConfig`, `ValidationConfig`, and `initializationOptions`. When a feature graduates to stable, remove its flag and make the behavior unconditional.
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/expressions",
|
"name": "@actions/expressions",
|
||||||
"version": "0.3.54",
|
"version": "0.3.34",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {DescriptionPair} from "./completion/descriptionDictionary.js";
|
|||||||
import {Dictionary, isDictionary} from "./data/dictionary.js";
|
import {Dictionary, isDictionary} from "./data/dictionary.js";
|
||||||
import {ExpressionData} from "./data/expressiondata.js";
|
import {ExpressionData} from "./data/expressiondata.js";
|
||||||
import {Evaluator} from "./evaluator.js";
|
import {Evaluator} from "./evaluator.js";
|
||||||
import {FeatureFlags} from "./features.js";
|
|
||||||
import {wellKnownFunctions} from "./funcs.js";
|
import {wellKnownFunctions} from "./funcs.js";
|
||||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||||
import {Lexer, Token, TokenType} from "./lexer.js";
|
import {Lexer, Token, TokenType} from "./lexer.js";
|
||||||
@@ -27,15 +26,13 @@ export type CompletionItem = {
|
|||||||
* @param context Context available for the expression
|
* @param context Context available for the expression
|
||||||
* @param extensionFunctions List of functions available
|
* @param extensionFunctions List of functions available
|
||||||
* @param functions Optional map of functions to use during evaluation
|
* @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
|
* @returns Array of completion items
|
||||||
*/
|
*/
|
||||||
export function complete(
|
export function complete(
|
||||||
input: string,
|
input: string,
|
||||||
context: Dictionary,
|
context: Dictionary,
|
||||||
extensionFunctions: FunctionInfo[],
|
extensionFunctions: FunctionInfo[],
|
||||||
functions?: Map<string, FunctionDefinition>,
|
functions?: Map<string, FunctionDefinition>
|
||||||
featureFlags?: FeatureFlags
|
|
||||||
): CompletionItem[] {
|
): CompletionItem[] {
|
||||||
// Lex
|
// Lex
|
||||||
const lexer = new Lexer(input);
|
const lexer = new Lexer(input);
|
||||||
@@ -66,7 +63,7 @@ export function complete(
|
|||||||
const result = contextKeys(context);
|
const result = contextKeys(context);
|
||||||
|
|
||||||
// Merge with functions
|
// Merge with functions
|
||||||
result.push(...functionItems(extensionFunctions, featureFlags));
|
result.push(...functionItems(extensionFunctions));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -91,15 +88,10 @@ export function complete(
|
|||||||
return contextKeys(result);
|
return contextKeys(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): CompletionItem[] {
|
function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] {
|
||||||
const result: CompletionItem[] = [];
|
const result: CompletionItem[] = [];
|
||||||
const flags = featureFlags ?? new FeatureFlags();
|
|
||||||
|
|
||||||
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
|
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
|
||||||
// Filter out case function if feature is disabled
|
|
||||||
if (fdef.name === "case" && !flags.isEnabled("allowCaseFunction")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result.push({
|
result.push({
|
||||||
label: fdef.name,
|
label: fdef.name,
|
||||||
description: fdef.description,
|
description: fdef.description,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export enum ErrorType {
|
|||||||
ErrorExceededMaxLength,
|
ErrorExceededMaxLength,
|
||||||
ErrorTooFewParameters,
|
ErrorTooFewParameters,
|
||||||
ErrorTooManyParameters,
|
ErrorTooManyParameters,
|
||||||
ErrorEvenParameters,
|
|
||||||
ErrorUnrecognizedContext,
|
ErrorUnrecognizedContext,
|
||||||
ErrorUnrecognizedFunction
|
ErrorUnrecognizedFunction
|
||||||
}
|
}
|
||||||
@@ -43,8 +42,6 @@ function errorDescription(typ: ErrorType): string {
|
|||||||
return "Too few parameters supplied";
|
return "Too few parameters supplied";
|
||||||
case ErrorType.ErrorTooManyParameters:
|
case ErrorType.ErrorTooManyParameters:
|
||||||
return "Too many parameters supplied";
|
return "Too many parameters supplied";
|
||||||
case ErrorType.ErrorEvenParameters:
|
|
||||||
return "Even number of parameters supplied, requires an odd number of parameters";
|
|
||||||
case ErrorType.ErrorUnrecognizedContext:
|
case ErrorType.ErrorUnrecognizedContext:
|
||||||
return "Unrecognized named-value";
|
return "Unrecognized named-value";
|
||||||
case ErrorType.ErrorUnrecognizedFunction:
|
case ErrorType.ErrorUnrecognizedFunction:
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ describe("FeatureFlags", () => {
|
|||||||
it("returns true when all is enabled", () => {
|
it("returns true when all is enabled", () => {
|
||||||
const flags = new FeatureFlags({all: true});
|
const flags = new FeatureFlags({all: true});
|
||||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||||
expect(flags.isEnabled("allowConcurrencyQueue")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("explicit feature flag takes precedence over all:true", () => {
|
it("explicit feature flag takes precedence over all:true", () => {
|
||||||
@@ -52,13 +51,7 @@ describe("FeatureFlags", () => {
|
|||||||
|
|
||||||
it("returns all features when all is enabled", () => {
|
it("returns all features when all is enabled", () => {
|
||||||
const flags = new FeatureFlags({all: true});
|
const flags = new FeatureFlags({all: true});
|
||||||
expect(flags.getEnabledFeatures()).toEqual([
|
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
|
||||||
"missingInputsQuickfix",
|
|
||||||
"blockScalarChompingWarning",
|
|
||||||
"allowCaseFunction",
|
|
||||||
"allowCopilotRequestsPermission",
|
|
||||||
"allowConcurrencyQueue"
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,31 +21,6 @@ export interface ExperimentalFeatures {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
missingInputsQuickfix?: boolean;
|
missingInputsQuickfix?: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* Warn when block scalars (| or >) use implicit clip chomping,
|
|
||||||
* which adds a trailing newline that may be unintentional.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
blockScalarChompingWarning?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable the case() function in expressions.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
allowCaseFunction?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable the copilot-requests permission in workflow permissions.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
allowCopilotRequestsPermission?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable the queue property in workflow concurrency settings.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
allowConcurrencyQueue?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,13 +32,7 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
|
|||||||
* All known experimental feature keys.
|
* All known experimental feature keys.
|
||||||
* This list must be kept in sync with the ExperimentalFeatures interface.
|
* This list must be kept in sync with the ExperimentalFeatures interface.
|
||||||
*/
|
*/
|
||||||
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
const allFeatureKeys: ExperimentalFeatureKey[] = ["missingInputsQuickfix"];
|
||||||
"missingInputsQuickfix",
|
|
||||||
"blockScalarChompingWarning",
|
|
||||||
"allowCaseFunction",
|
|
||||||
"allowCopilotRequestsPermission",
|
|
||||||
"allowConcurrencyQueue"
|
|
||||||
];
|
|
||||||
|
|
||||||
export class FeatureFlags {
|
export class FeatureFlags {
|
||||||
private readonly features: ExperimentalFeatures;
|
private readonly features: ExperimentalFeatures;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {ErrorType, ExpressionError} from "./errors.js";
|
import {ErrorType, ExpressionError} from "./errors.js";
|
||||||
import {caseFunc} from "./funcs/case.js";
|
|
||||||
import {contains} from "./funcs/contains.js";
|
import {contains} from "./funcs/contains.js";
|
||||||
import {endswith} from "./funcs/endswith.js";
|
import {endswith} from "./funcs/endswith.js";
|
||||||
import {format} from "./funcs/format.js";
|
import {format} from "./funcs/format.js";
|
||||||
@@ -17,7 +16,6 @@ export type ParseContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
|
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
|
||||||
case: caseFunc,
|
|
||||||
contains: contains,
|
contains: contains,
|
||||||
endswith: endswith,
|
endswith: endswith,
|
||||||
format: format,
|
format: format,
|
||||||
@@ -55,9 +53,4 @@ export function validateFunction(context: ParseContext, identifier: Token, argCo
|
|||||||
if (argCount > f.maxArgs) {
|
if (argCount > f.maxArgs) {
|
||||||
throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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
@@ -1,157 +0,0 @@
|
|||||||
{
|
|
||||||
"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')"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -126,8 +126,6 @@ initializationOptions: {
|
|||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
|
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
|
||||||
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
|
|
||||||
| `allowConcurrencyQueue` | Enable the `concurrency.queue` workflow property |
|
|
||||||
|
|
||||||
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
|
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageserver",
|
"name": "@actions/languageserver",
|
||||||
"version": "0.3.54",
|
"version": "0.3.34",
|
||||||
"description": "Language server for GitHub Actions",
|
"description": "Language server for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -48,8 +48,8 @@
|
|||||||
"actions-languageserver": "./bin/actions-languageserver"
|
"actions-languageserver": "./bin/actions-languageserver"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/languageservice": "^0.3.54",
|
"@actions/languageservice": "^0.3.34",
|
||||||
"@actions/workflow-parser": "^0.3.54",
|
"@actions/workflow-parser": "^0.3.34",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
"@octokit/types": "^9.0.0",
|
"@octokit/types": "^9.0.0",
|
||||||
"vscode-languageserver": "^8.0.2",
|
"vscode-languageserver": "^8.0.2",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"yaml": "^2.1.3"
|
"yaml": "^2.1.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
@@ -73,10 +73,9 @@
|
|||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"fetch-mock": "^9.11.0",
|
"fetch-mock": "^9.11.0",
|
||||||
"jest": "^29.0.3",
|
"jest": "^29.0.3",
|
||||||
"node-fetch": "^2.6.7",
|
|
||||||
"prettier": "^2.8.3",
|
"prettier": "^2.8.3",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^4.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import {
|
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
|
||||||
documentLinks,
|
|
||||||
getCodeActions,
|
|
||||||
getInlayHints,
|
|
||||||
hover,
|
|
||||||
validate,
|
|
||||||
ValidationConfig
|
|
||||||
} from "@actions/languageservice";
|
|
||||||
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
||||||
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {
|
import {
|
||||||
CodeAction,
|
|
||||||
CodeActionKind,
|
|
||||||
CodeActionParams,
|
|
||||||
CompletionItem,
|
CompletionItem,
|
||||||
Connection,
|
Connection,
|
||||||
DocumentLink,
|
DocumentLink,
|
||||||
@@ -89,10 +79,7 @@ export function initConnection(connection: Connection) {
|
|||||||
documentLinkProvider: {
|
documentLinkProvider: {
|
||||||
resolveProvider: false
|
resolveProvider: false
|
||||||
},
|
},
|
||||||
inlayHintProvider: true,
|
inlayHintProvider: true
|
||||||
codeActionProvider: {
|
|
||||||
codeActionKinds: [CodeActionKind.QuickFix]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,8 +123,7 @@ export function initConnection(connection: Connection) {
|
|||||||
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
||||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||||
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
||||||
}),
|
})
|
||||||
featureFlags
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validate(textDocument, config);
|
const result = await validate(textDocument, config);
|
||||||
@@ -154,8 +140,7 @@ export function initConnection(connection: Connection) {
|
|||||||
getDocument(documents, textDocument),
|
getDocument(documents, textDocument),
|
||||||
client,
|
client,
|
||||||
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
|
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
|
||||||
cache,
|
cache
|
||||||
featureFlags
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -191,17 +176,6 @@ 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
|
// Make the text document manager listen on the connection
|
||||||
// for open, change and close text document events
|
// for open, change and close text document events
|
||||||
documents.listen(connection);
|
documents.listen(connection);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {complete} from "@actions/languageservice/complete";
|
import {complete} from "@actions/languageservice/complete";
|
||||||
import type {FeatureFlags} from "@actions/expressions";
|
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
@@ -16,13 +15,11 @@ export async function onCompletion(
|
|||||||
document: TextDocument,
|
document: TextDocument,
|
||||||
client: Octokit | undefined,
|
client: Octokit | undefined,
|
||||||
repoContext: RepositoryContext | undefined,
|
repoContext: RepositoryContext | undefined,
|
||||||
cache: TTLCache,
|
cache: TTLCache
|
||||||
featureFlags?: FeatureFlags
|
|
||||||
): Promise<CompletionItem[]> {
|
): Promise<CompletionItem[]> {
|
||||||
return await complete(document, position, {
|
return await complete(document, position, {
|
||||||
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
|
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
|
||||||
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
|
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
|
||||||
featureFlags,
|
|
||||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||||
return await connection.sendRequest(Requests.ReadFile, {path});
|
return await connection.sendRequest(Requests.ReadFile, {path});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {TTLCache} from "./utils/cache.js";
|
|||||||
import {getActionInputValues} from "./value-providers/action-inputs.js";
|
import {getActionInputValues} from "./value-providers/action-inputs.js";
|
||||||
import {getEnvironments} from "./value-providers/job-environment.js";
|
import {getEnvironments} from "./value-providers/job-environment.js";
|
||||||
import {getRunnerLabels} from "./value-providers/runs-on.js";
|
import {getRunnerLabels} from "./value-providers/runs-on.js";
|
||||||
|
import {getStepUsesValues} from "./value-providers/step-uses.js";
|
||||||
|
|
||||||
export function valueProviders(
|
export function valueProviders(
|
||||||
client: Octokit | undefined,
|
client: Octokit | undefined,
|
||||||
@@ -35,6 +36,10 @@ export function valueProviders(
|
|||||||
"step-with": {
|
"step-with": {
|
||||||
kind: ValueProviderKind.AllowedValues,
|
kind: ValueProviderKind.AllowedValues,
|
||||||
get: (context: WorkflowContext) => getActionInputValues(client, cache, context)
|
get: (context: WorkflowContext) => getActionInputValues(client, cache, context)
|
||||||
|
},
|
||||||
|
"step-uses": {
|
||||||
|
kind: ValueProviderKind.SuggestedValues,
|
||||||
|
get: (context: WorkflowContext) => getStepUsesValues(context)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||||
|
import {Value} from "@actions/languageservice/value-providers/config";
|
||||||
|
|
||||||
|
const MARKETPLACE_SEARCH_URL = "https://github.com/editor/actions/marketplace-search";
|
||||||
|
const MARKETPLACE_DETAIL_URL = "https://github.com/editor/actions/marketplace";
|
||||||
|
const MIN_QUERY_LENGTH = 3;
|
||||||
|
const COMPLETION_KIND_MODULE = 9; // CompletionItemKind.Module
|
||||||
|
|
||||||
|
interface MarketplaceAction {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
owner: string;
|
||||||
|
stars: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses star count strings like "1.3k" or "205" into numbers
|
||||||
|
*/
|
||||||
|
function parseStarsCount(starsStr: string): number {
|
||||||
|
const trimmed = starsStr.trim().toLowerCase();
|
||||||
|
if (trimmed.endsWith("k")) {
|
||||||
|
return Math.round(parseFloat(trimmed.slice(0, -1)) * 1000);
|
||||||
|
}
|
||||||
|
return parseInt(trimmed, 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the search results HTML to extract action metadata
|
||||||
|
*/
|
||||||
|
function parseSearchResults(html: string): MarketplaceAction[] {
|
||||||
|
const actions: MarketplaceAction[] = [];
|
||||||
|
|
||||||
|
// Split by action item markers and process each chunk
|
||||||
|
const chunks = html.split('js-marketplace-action-search-item"');
|
||||||
|
|
||||||
|
// Skip the first chunk (before any action items)
|
||||||
|
for (let i = 1; i < chunks.length; i++) {
|
||||||
|
const itemHtml = chunks[i];
|
||||||
|
|
||||||
|
// Extract marketplace ID from the sidebar URL
|
||||||
|
const idMatch = itemHtml.match(/data-workflow-editor-sidebar-url="[^"]*\/marketplace\/(\d+)/);
|
||||||
|
if (!idMatch) continue;
|
||||||
|
const id = idMatch[1];
|
||||||
|
|
||||||
|
// Extract title from aria-label on first button
|
||||||
|
const titleMatch = itemHtml.match(/aria-label="([^"]+)"/);
|
||||||
|
const title = titleMatch ? titleMatch[1] : "";
|
||||||
|
|
||||||
|
// Extract owner - look for "By <span...>owner</span>"
|
||||||
|
const ownerMatch = itemHtml.match(/By\s*<span[^>]*>([^<]+)<\/span>/);
|
||||||
|
const owner = ownerMatch ? ownerMatch[1].trim() : "";
|
||||||
|
|
||||||
|
// Extract stars - in the float-right span
|
||||||
|
const starsMatch = itemHtml.match(
|
||||||
|
/<span class="float-right text-small color-fg-muted[^"]*">\s*<svg[^>]*>[\s\S]*?<\/svg>\s*([^<]+)/
|
||||||
|
);
|
||||||
|
const stars = starsMatch ? parseStarsCount(starsMatch[1]) : 0;
|
||||||
|
|
||||||
|
// Extract description - the last paragraph with ws-normal class
|
||||||
|
const descMatch = itemHtml.match(
|
||||||
|
/<p class="color-fg-muted lh-condensed mb-0 ws-normal">([^<]+)<\/p>/
|
||||||
|
);
|
||||||
|
const description = descMatch ? descMatch[1].trim() : "";
|
||||||
|
|
||||||
|
actions.push({id, title, owner, stars, description});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the action detail page to extract the uses value (e.g., "owner/repo@version")
|
||||||
|
* Prefers version tags over commit SHAs
|
||||||
|
*/
|
||||||
|
function parseActionDetail(html: string): string | undefined {
|
||||||
|
// Look for uses: lines - there are typically two formats:
|
||||||
|
// 1. SHA: uses: owner/repo@abc123...
|
||||||
|
// 2. Version: uses: owner/[email protected]
|
||||||
|
// We prefer the version format (shorter, contains 'v')
|
||||||
|
const usesMatches = html.match(/uses:\s*([\w-]+\/[\w._-]+@[\w.+-]+)/g);
|
||||||
|
if (!usesMatches || usesMatches.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first version tag (contains @v) or fall back to the first match
|
||||||
|
for (const match of usesMatches) {
|
||||||
|
const value = match.replace(/^uses:\s*/, "");
|
||||||
|
if (value.includes("@v")) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to first match (likely SHA)
|
||||||
|
return usesMatches[0].replace(/^uses:\s*/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches marketplace search results and detail pages to build completion values
|
||||||
|
*/
|
||||||
|
export async function getStepUsesValues(context: WorkflowContext): Promise<Value[]> {
|
||||||
|
const query = context.currentValue?.trim() || "";
|
||||||
|
|
||||||
|
// Don't search with very short queries
|
||||||
|
if (query.length < MIN_QUERY_LENGTH) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch search results
|
||||||
|
const searchUrl = `${MARKETPLACE_SEARCH_URL}?query=${encodeURIComponent(query)}`;
|
||||||
|
const searchResponse = await fetch(searchUrl);
|
||||||
|
if (!searchResponse.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const searchHtml = await searchResponse.text();
|
||||||
|
const actions = parseSearchResults(searchHtml);
|
||||||
|
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch detail pages in parallel to get the actual uses values
|
||||||
|
const detailPromises = actions.map(async (action): Promise<Value | null> => {
|
||||||
|
try {
|
||||||
|
const detailUrl = `${MARKETPLACE_DETAIL_URL}/${action.id}`;
|
||||||
|
const detailResponse = await fetch(detailUrl);
|
||||||
|
if (!detailResponse.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const detailHtml = await detailResponse.text();
|
||||||
|
const usesValue = parseActionDetail(detailHtml);
|
||||||
|
|
||||||
|
if (!usesValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sort text that puts higher star counts first
|
||||||
|
// Pad to 10 digits so sorting works correctly
|
||||||
|
const invertedStars = 10000000000 - action.stars;
|
||||||
|
const sortText = invertedStars.toString().padStart(10, "0");
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: usesValue,
|
||||||
|
description: action.description,
|
||||||
|
kind: COMPLETION_KIND_MODULE,
|
||||||
|
sortText
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(detailPromises);
|
||||||
|
|
||||||
|
// Filter out nulls and return
|
||||||
|
return results.filter((v): v is Value => v !== null);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageservice",
|
"name": "@actions/languageservice",
|
||||||
"version": "0.3.54",
|
"version": "0.3.34",
|
||||||
"description": "Language service for GitHub Actions",
|
"description": "Language service for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -47,15 +47,15 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.54",
|
"@actions/expressions": "^0.3.34",
|
||||||
"@actions/workflow-parser": "^0.3.54",
|
"@actions/workflow-parser": "^0.3.34",
|
||||||
"vscode-languageserver-textdocument": "^1.0.7",
|
"vscode-languageserver-textdocument": "^1.0.7",
|
||||||
"vscode-languageserver-types": "^3.17.2",
|
"vscode-languageserver-types": "^3.17.2",
|
||||||
"vscode-uri": "^3.0.8",
|
"vscode-uri": "^3.0.8",
|
||||||
"yaml": "^2.1.1"
|
"yaml": "^2.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
@@ -74,6 +74,6 @@
|
|||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^4.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
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";
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
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
@@ -1,9 +0,0 @@
|
|||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/cache@v1
|
|
||||||
with:
|
|
||||||
path: ""
|
|
||||||
key: ""
|
|
||||||
Vendored
-7
@@ -1,7 +0,0 @@
|
|||||||
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
@@ -1,10 +0,0 @@
|
|||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/cache@v1
|
|
||||||
with:
|
|
||||||
restore-keys: ${{ runner.os }}-
|
|
||||||
path: ""
|
|
||||||
key: ""
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
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
@@ -1,9 +0,0 @@
|
|||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/cache@v1
|
|
||||||
with:
|
|
||||||
path: ""
|
|
||||||
key: ""
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
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
@@ -1,9 +0,0 @@
|
|||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/cache@v1
|
|
||||||
with:
|
|
||||||
path: ""
|
|
||||||
key: ""
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -134,49 +134,6 @@ runs:
|
|||||||
expect(labels).toContain("arch");
|
expect(labels).toContain("arch");
|
||||||
expect(labels).toContain("temp");
|
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", () => {
|
describe("top-level completions", () => {
|
||||||
@@ -250,85 +207,6 @@ runs:
|
|||||||
expect(labels).not.toContain("entrypoint");
|
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 () => {
|
it("filters runs keys for composite actions", async () => {
|
||||||
const [doc, position] = createActionDocument(`name: Test
|
const [doc, position] = createActionDocument(`name: Test
|
||||||
description: Test
|
description: Test
|
||||||
@@ -381,8 +259,8 @@ runs:
|
|||||||
const usingCompletion = completions.find(c => c.label === "using");
|
const usingCompletion = completions.find(c => c.label === "using");
|
||||||
expect(usingCompletion).toBeDefined();
|
expect(usingCompletion).toBeDefined();
|
||||||
|
|
||||||
// It should have a sortText that makes it sort after snippets
|
// It should have a sortText that makes it sort first
|
||||||
expect(usingCompletion?.sortText).toBe("9_using");
|
expect(usingCompletion?.sortText).toBe("0_using");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("completes step keys inside composite action steps", async () => {
|
it("completes step keys inside composite action steps", async () => {
|
||||||
@@ -515,175 +393,4 @@ runs:
|
|||||||
expect(labels).toContain("jobs");
|
expect(labels).toContain("jobs");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("action scaffolding snippets", () => {
|
|
||||||
it("offers full scaffolding snippets in empty file", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
expect(labels).toContain("Node.js Action");
|
|
||||||
expect(labels).toContain("Composite Action");
|
|
||||||
expect(labels).toContain("Docker Action");
|
|
||||||
|
|
||||||
// Verify they are snippets
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
|
||||||
expect(nodeSnippet?.kind).toBe(15); // CompletionItemKind.Snippet
|
|
||||||
expect(nodeSnippet?.insertTextFormat).toBe(2); // InsertTextFormat.Snippet
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers full scaffolding snippets when no name or description exists", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`author: me
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
|
||||||
expect(nodeSnippet).toBeDefined();
|
|
||||||
// Full snippet should include name:
|
|
||||||
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("name:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers runs-only snippets when name exists", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
|
||||||
expect(nodeSnippet).toBeDefined();
|
|
||||||
// Runs-only snippet should start with inputs:, not name:
|
|
||||||
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/);
|
|
||||||
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers runs-only snippets when description exists", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`description: Does something
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
|
|
||||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
|
||||||
expect(compositeSnippet).toBeDefined();
|
|
||||||
// Runs-only snippet should start with inputs:, not description:
|
|
||||||
expect((compositeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/);
|
|
||||||
expect((compositeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not offer snippets when runs.using already exists", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
|`);
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers snippets inside runs when using is not set", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
expect(labels).toContain("Node.js Action");
|
|
||||||
expect(labels).toContain("Composite Action");
|
|
||||||
expect(labels).toContain("Docker Action");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not offer snippets at root level when runs exists", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
steps: []
|
|
||||||
|`);
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not offer snippets when nested inside runs steps", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- |`);
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Node.js snippet contains expected content", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
|
||||||
const text = (nodeSnippet?.textEdit as {newText: string})?.newText;
|
|
||||||
|
|
||||||
expect(text).toContain("using: node24");
|
|
||||||
expect(text).toContain("main:");
|
|
||||||
expect(text).toContain("inputs:");
|
|
||||||
expect(text).toContain("outputs:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Composite snippet contains expected content", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
|
|
||||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
|
||||||
const text = (compositeSnippet?.textEdit as {newText: string})?.newText;
|
|
||||||
|
|
||||||
expect(text).toContain("using: composite");
|
|
||||||
expect(text).toContain("steps:");
|
|
||||||
expect(text).toContain("shell: bash");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Docker snippet contains expected content", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
|
|
||||||
const dockerSnippet = completions.find(c => c.label === "Docker Action");
|
|
||||||
const text = (dockerSnippet?.textEdit as {newText: string})?.newText;
|
|
||||||
|
|
||||||
expect(text).toContain("using: docker");
|
|
||||||
expect(text).toContain("image:");
|
|
||||||
expect(text).toContain("entrypoint:");
|
|
||||||
});
|
|
||||||
|
|
||||||
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 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,480 +0,0 @@
|
|||||||
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, Range, TextEdit} from "vscode-languageserver-types";
|
|
||||||
import {Value} from "./value-providers/config.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid keys for each action type under the `runs:` section.
|
|
||||||
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
|
|
||||||
*/
|
|
||||||
const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
|
|
||||||
const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]);
|
|
||||||
const ACTION_DOCKER_KEYS = new Set([
|
|
||||||
"using",
|
|
||||||
"image",
|
|
||||||
"args",
|
|
||||||
"env",
|
|
||||||
"entrypoint",
|
|
||||||
"pre-entrypoint",
|
|
||||||
"pre-if",
|
|
||||||
"post-entrypoint",
|
|
||||||
"post-if"
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Action scaffolding snippets.
|
|
||||||
*
|
|
||||||
* Full variants include name, description, inputs, outputs, and runs.
|
|
||||||
* Runs-only variants include just the runs block.
|
|
||||||
*/
|
|
||||||
const ACTION_SNIPPET_NODEJS_FULL = `name: '\${1:Action Name}'
|
|
||||||
description: '\${2:What this action does}'
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# 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):
|
|
||||||
#
|
|
||||||
# const fs = require('fs');
|
|
||||||
# const name = process.env.INPUT_NAME || 'World';
|
|
||||||
# const greeting = \\\`Hello \\\${name}\\\`;
|
|
||||||
# console.log(greeting);
|
|
||||||
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_NODEJS_RUNS = `inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# 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):
|
|
||||||
#
|
|
||||||
# const fs = require('fs');
|
|
||||||
# const name = process.env.INPUT_NAME || 'World';
|
|
||||||
# const greeting = \\\`Hello \\\${name}\\\`;
|
|
||||||
# console.log(greeting);
|
|
||||||
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
|
|
||||||
`;
|
|
||||||
|
|
||||||
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');
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_COMPOSITE_FULL = `name: '\${1:Action Name}'
|
|
||||||
description: '\${2:What this action does}'
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
value: \\\${{ steps.greet.outputs.greeting }}
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# For more on composite actions, see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- id: greet
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
INPUT_NAME: \\\${{ inputs.name }}
|
|
||||||
run: |
|
|
||||||
GREETING="Hello \\$INPUT_NAME"
|
|
||||||
echo "\\$GREETING"
|
|
||||||
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_COMPOSITE_RUNS = `inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
value: \\\${{ steps.greet.outputs.greeting }}
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# For more on composite actions, see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- id: greet
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
INPUT_NAME: \\\${{ inputs.name }}
|
|
||||||
run: |
|
|
||||||
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"
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_DOCKER_FULL = `name: '\${1:Action Name}'
|
|
||||||
description: '\${2:What this action does}'
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# 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: '\${3:docker://alpine:3.20}'
|
|
||||||
env:
|
|
||||||
INPUT_NAME: \\\${{ inputs.name }}
|
|
||||||
entrypoint: '\${4:sh}'
|
|
||||||
args:
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
GREETING="Hello \\$INPUT_NAME"
|
|
||||||
echo "\\$GREETING"
|
|
||||||
echo "greeting=\\$GREETING" >> \\$GITHUB_OUTPUT
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_DOCKER_RUNS = `inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# 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}'
|
|
||||||
env:
|
|
||||||
INPUT_NAME: \\\${{ inputs.name }}
|
|
||||||
entrypoint: '\${2:sh}'
|
|
||||||
args:
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
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"
|
|
||||||
`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters action.yml `runs:` completions based on the `using:` value.
|
|
||||||
*
|
|
||||||
* When the user is completing keys under `runs:`:
|
|
||||||
* - If `using: node20` is set, only show Node.js action keys
|
|
||||||
* - If `using: composite` is set, only show composite action keys
|
|
||||||
* - If `using: docker` is set, only show Docker action keys
|
|
||||||
* - If `using:` is not set, show all keys but prioritize `using` first
|
|
||||||
*/
|
|
||||||
export function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] {
|
|
||||||
// Find the runs mapping from the root
|
|
||||||
let runsMapping: MappingToken | undefined;
|
|
||||||
if (root instanceof MappingToken) {
|
|
||||||
for (let i = 0; i < root.count; i++) {
|
|
||||||
const {key, value} = root.get(i);
|
|
||||||
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
|
||||||
runsMapping = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!runsMapping) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the runs mapping is in our path (meaning we're completing inside it)
|
|
||||||
const isInsideRuns = path.some(token => token === runsMapping);
|
|
||||||
if (!isInsideRuns) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find where runsMapping is in the path
|
|
||||||
const runsMappingIndex = path.indexOf(runsMapping);
|
|
||||||
if (runsMappingIndex === -1) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's anything after runsMapping in the path
|
|
||||||
// If so, we're nested deeper (e.g., inside steps sequence or a step mapping)
|
|
||||||
if (runsMappingIndex < path.length - 1) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the using value from the runs mapping
|
|
||||||
let usingValue: string | undefined;
|
|
||||||
for (let i = 0; i < runsMapping.count; i++) {
|
|
||||||
const {key, value} = runsMapping.get(i);
|
|
||||||
if (key.toString().toLowerCase() === "using") {
|
|
||||||
usingValue = value.toString();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which keys to allow
|
|
||||||
let allowedKeys: Set<string>;
|
|
||||||
|
|
||||||
if (!usingValue) {
|
|
||||||
// No using value set - show all keys but prioritize "using"
|
|
||||||
return values.map(v => {
|
|
||||||
if (v.label.toLowerCase() === "using") {
|
|
||||||
return {...v, sortText: "9_using"}; // Sort after snippets (0_, 1_, 2_)
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
});
|
|
||||||
} else if (usingValue.match(/^node\d+$/i)) {
|
|
||||||
allowedKeys = ACTION_NODE_KEYS;
|
|
||||||
} else if (usingValue.toLowerCase() === "composite") {
|
|
||||||
allowedKeys = ACTION_COMPOSITE_KEYS;
|
|
||||||
} else if (usingValue.toLowerCase() === "docker") {
|
|
||||||
allowedKeys = ACTION_DOCKER_KEYS;
|
|
||||||
} else {
|
|
||||||
// Unknown using value - show all
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to only allowed keys
|
|
||||||
return values.filter(v => allowedKeys.has(v.label.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets action scaffolding snippet completions for action.yml files.
|
|
||||||
*
|
|
||||||
* Returns snippet completions when `runs.using` is not present, offering
|
|
||||||
* three action types: Node.js, Composite, and Docker.
|
|
||||||
*
|
|
||||||
* Three variants per type:
|
|
||||||
* - "_FULL": Full scaffold with name, description, inputs, outputs, and runs
|
|
||||||
* - "_RUNS": Inputs, outputs, and runs (when name/description already exists)
|
|
||||||
* - "_USING": Minimal runs content (when inside `runs:` mapping)
|
|
||||||
*
|
|
||||||
* Which variant is shown depends on context:
|
|
||||||
* - Inside `runs:` mapping → "_USING" variants
|
|
||||||
* - At root with name/description → "_RUNS" variants
|
|
||||||
* - At root without name/description → "_FULL" variants
|
|
||||||
*/
|
|
||||||
export function getActionScaffoldingSnippets(
|
|
||||||
root: TemplateToken | undefined,
|
|
||||||
path: TemplateToken[],
|
|
||||||
position: Position,
|
|
||||||
replaceRange?: Range
|
|
||||||
): CompletionItem[] {
|
|
||||||
// Get the runs mapping from the root, if it exists
|
|
||||||
let runsMapping: MappingToken | undefined;
|
|
||||||
if (root instanceof MappingToken) {
|
|
||||||
for (let i = 0; i < root.count; i++) {
|
|
||||||
const {key, value} = root.get(i);
|
|
||||||
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
|
||||||
runsMapping = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if runs.using already exists - if so, no scaffolding needed
|
|
||||||
if (runsMapping) {
|
|
||||||
for (let i = 0; i < runsMapping.count; i++) {
|
|
||||||
const {key} = runsMapping.get(i);
|
|
||||||
if (key.toString().toLowerCase() === "using") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show "_USING" variants directly inside `runs`
|
|
||||||
const runsMappingIndex = runsMapping ? path.indexOf(runsMapping) : -1;
|
|
||||||
const isDirectlyInsideRuns = runsMappingIndex !== -1 && runsMappingIndex === path.length - 1;
|
|
||||||
if (isDirectlyInsideRuns) {
|
|
||||||
return [
|
|
||||||
createSnippetCompletion(
|
|
||||||
"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,
|
|
||||||
"0_nodejs",
|
|
||||||
replaceRange
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"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,
|
|
||||||
"1_composite",
|
|
||||||
replaceRange
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"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,
|
|
||||||
"2_docker",
|
|
||||||
replaceRange
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not at root or `runs` already exists?
|
|
||||||
const isAtRoot = path.length === 0 || (path.length === 1 && path[0] === root);
|
|
||||||
if (!isAtRoot || runsMapping) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which variant to show based on existing root keys
|
|
||||||
let hasNameOrDescription = false;
|
|
||||||
if (root instanceof MappingToken) {
|
|
||||||
for (let i = 0; i < root.count; i++) {
|
|
||||||
const keyStr = root.get(i).key.toString().toLowerCase();
|
|
||||||
if (keyStr === "name" || keyStr === "description") {
|
|
||||||
hasNameOrDescription = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show "_RUNS" variants (inputs, outputs, and runs block)
|
|
||||||
if (hasNameOrDescription) {
|
|
||||||
return [
|
|
||||||
createSnippetCompletion(
|
|
||||||
"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",
|
|
||||||
replaceRange
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"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",
|
|
||||||
replaceRange
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"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",
|
|
||||||
replaceRange
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show "_FULL" variants (complete scaffold)
|
|
||||||
return [
|
|
||||||
createSnippetCompletion(
|
|
||||||
"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",
|
|
||||||
replaceRange
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"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",
|
|
||||||
replaceRange
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"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",
|
|
||||||
replaceRange
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a snippet completion item.
|
|
||||||
*/
|
|
||||||
function createSnippetCompletion(
|
|
||||||
label: string,
|
|
||||||
description: string,
|
|
||||||
snippetText: string,
|
|
||||||
position: Position,
|
|
||||||
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",
|
|
||||||
value: description
|
|
||||||
},
|
|
||||||
insertTextFormat: InsertTextFormat.Snippet,
|
|
||||||
sortText,
|
|
||||||
textEdit
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import {data, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {CompletionItem, CompletionItemKind, MarkupContent} from "vscode-languageserver-types";
|
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
|
||||||
import {complete, getExpressionInput} from "./complete.js";
|
import {complete, getExpressionInput} from "./complete.js";
|
||||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||||
import {registerLogger} from "./log.js";
|
import {registerLogger} from "./log.js";
|
||||||
@@ -68,16 +68,12 @@ describe("expressions", () => {
|
|||||||
describe("top-level auto-complete", () => {
|
describe("top-level auto-complete", () => {
|
||||||
it("single region", async () => {
|
it("single region", async () => {
|
||||||
const input = "run-name: ${{ | }}";
|
const input = "run-name: ${{ | }}";
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
contextProviderConfig,
|
|
||||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual([
|
||||||
"github",
|
"github",
|
||||||
"inputs",
|
"inputs",
|
||||||
"vars",
|
"vars",
|
||||||
"case",
|
|
||||||
"contains",
|
"contains",
|
||||||
"endsWith",
|
"endsWith",
|
||||||
"format",
|
"format",
|
||||||
@@ -112,16 +108,12 @@ describe("expressions", () => {
|
|||||||
|
|
||||||
it("single region with existing input", async () => {
|
it("single region with existing input", async () => {
|
||||||
const input = "run-name: ${{ g| }}";
|
const input = "run-name: ${{ g| }}";
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
contextProviderConfig,
|
|
||||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual([
|
||||||
"github",
|
"github",
|
||||||
"inputs",
|
"inputs",
|
||||||
"vars",
|
"vars",
|
||||||
"case",
|
|
||||||
"contains",
|
"contains",
|
||||||
"endsWith",
|
"endsWith",
|
||||||
"format",
|
"format",
|
||||||
@@ -134,16 +126,12 @@ describe("expressions", () => {
|
|||||||
|
|
||||||
it("single region with existing condition", async () => {
|
it("single region with existing condition", async () => {
|
||||||
const input = "run-name: ${{ g| == 'test' }}";
|
const input = "run-name: ${{ g| == 'test' }}";
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
contextProviderConfig,
|
|
||||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual([
|
||||||
"github",
|
"github",
|
||||||
"inputs",
|
"inputs",
|
||||||
"vars",
|
"vars",
|
||||||
"case",
|
|
||||||
"contains",
|
"contains",
|
||||||
"endsWith",
|
"endsWith",
|
||||||
"format",
|
"format",
|
||||||
@@ -156,16 +144,12 @@ describe("expressions", () => {
|
|||||||
|
|
||||||
it("multiple regions with partial function", async () => {
|
it("multiple regions with partial function", async () => {
|
||||||
const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}";
|
const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}";
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
contextProviderConfig,
|
|
||||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual([
|
||||||
"github",
|
"github",
|
||||||
"inputs",
|
"inputs",
|
||||||
"vars",
|
"vars",
|
||||||
"case",
|
|
||||||
"contains",
|
"contains",
|
||||||
"endsWith",
|
"endsWith",
|
||||||
"format",
|
"format",
|
||||||
@@ -178,16 +162,12 @@ describe("expressions", () => {
|
|||||||
|
|
||||||
it("multiple regions - first region", async () => {
|
it("multiple regions - first region", async () => {
|
||||||
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
|
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
contextProviderConfig,
|
|
||||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual([
|
||||||
"github",
|
"github",
|
||||||
"inputs",
|
"inputs",
|
||||||
"vars",
|
"vars",
|
||||||
"case",
|
|
||||||
"contains",
|
"contains",
|
||||||
"endsWith",
|
"endsWith",
|
||||||
"format",
|
"format",
|
||||||
@@ -200,16 +180,12 @@ describe("expressions", () => {
|
|||||||
|
|
||||||
it("multiple regions", async () => {
|
it("multiple regions", async () => {
|
||||||
const input = "run-name: test-${{ github }}-${{ | }}";
|
const input = "run-name: test-${{ github }}-${{ | }}";
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
contextProviderConfig,
|
|
||||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual([
|
||||||
"github",
|
"github",
|
||||||
"inputs",
|
"inputs",
|
||||||
"vars",
|
"vars",
|
||||||
"case",
|
|
||||||
"contains",
|
"contains",
|
||||||
"endsWith",
|
"endsWith",
|
||||||
"format",
|
"format",
|
||||||
@@ -419,36 +395,6 @@ jobs:
|
|||||||
|
|
||||||
expect(result.map(x => x.label)).toEqual(["event"]);
|
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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1164,16 +1110,7 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
|
||||||
"check_run_id",
|
|
||||||
"container",
|
|
||||||
"services",
|
|
||||||
"status",
|
|
||||||
"workflow_file_path",
|
|
||||||
"workflow_ref",
|
|
||||||
"workflow_repository",
|
|
||||||
"workflow_sha"
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("job context is suggested within a job output", async () => {
|
it("job context is suggested within a job output", async () => {
|
||||||
@@ -1189,10 +1126,7 @@ jobs:
|
|||||||
run: echo hi
|
run: echo hi
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
contextProviderConfig,
|
|
||||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
|
||||||
});
|
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual([
|
||||||
"env",
|
"env",
|
||||||
"github",
|
"github",
|
||||||
@@ -1205,7 +1139,6 @@ jobs:
|
|||||||
"steps",
|
"steps",
|
||||||
"strategy",
|
"strategy",
|
||||||
"vars",
|
"vars",
|
||||||
"case",
|
|
||||||
"contains",
|
"contains",
|
||||||
"endsWith",
|
"endsWith",
|
||||||
"format",
|
"format",
|
||||||
@@ -1317,7 +1250,6 @@ jobs:
|
|||||||
expect(hashFiles).toBeDefined();
|
expect(hashFiles).toBeDefined();
|
||||||
expect(hashFiles!.kind).toBe(CompletionItemKind.Function);
|
expect(hashFiles!.kind).toBe(CompletionItemKind.Function);
|
||||||
expect(hashFiles!.insertText).toBe("hashFiles()");
|
expect(hashFiles!.insertText).toBe("hashFiles()");
|
||||||
expect((hashFiles!.documentation as MarkupContent)?.value).toContain("Returns a single hash for the set of files");
|
|
||||||
|
|
||||||
// Not a function
|
// Not a function
|
||||||
const github = result.find(x => x.label === "github");
|
const github = result.find(x => x.label === "github");
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
|||||||
import {TestLogger} from "./test-utils/logger.js";
|
import {TestLogger} from "./test-utils/logger.js";
|
||||||
import {clearCache} from "./utils/workflow-cache.js";
|
import {clearCache} from "./utils/workflow-cache.js";
|
||||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||||
import {FeatureFlags} from "@actions/expressions/features";
|
|
||||||
|
|
||||||
registerLogger(new TestLogger());
|
registerLogger(new TestLogger());
|
||||||
|
|
||||||
@@ -20,8 +19,8 @@ describe("completion", () => {
|
|||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// 28 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
||||||
expect(result.length).toEqual(30);
|
expect(result.length).toEqual(14);
|
||||||
const labels = result.map(x => x.label);
|
const labels = result.map(x => x.label);
|
||||||
expect(labels).toContain("macos-latest");
|
expect(labels).toContain("macos-latest");
|
||||||
expect(labels).toContain("(switch to list)");
|
expect(labels).toContain("(switch to list)");
|
||||||
@@ -60,7 +59,7 @@ jobs:
|
|||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result.length).toEqual(27);
|
expect(result.length).toEqual(11);
|
||||||
|
|
||||||
const labels = result.map(x => x.label);
|
const labels = result.map(x => x.label);
|
||||||
expect(labels).toContain("macos-latest");
|
expect(labels).toContain("macos-latest");
|
||||||
@@ -896,157 +895,4 @@ jobs:
|
|||||||
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("expression completions", () => {
|
|
||||||
it("include case function when enabled", async () => {
|
|
||||||
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
|
||||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exclude case function when disabled", async () => {
|
|
||||||
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
|
||||||
featureFlags: new FeatureFlags({allowCaseFunction: false})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
|
||||||
// Expression completions starting with 'c': contains
|
|
||||||
const labels = result.map(x => x.label);
|
|
||||||
expect(labels).not.toContain("case");
|
|
||||||
expect(labels).toContain("contains");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("schedule timezone completion", () => {
|
|
||||||
it("includes timezone for schedule", async () => {
|
|
||||||
const input = `on:
|
|
||||||
schedule:
|
|
||||||
- |`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
|
||||||
const labels = result.map(x => x.label);
|
|
||||||
expect(labels).toContain("cron");
|
|
||||||
expect(labels).toContain("timezone");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("permissions copilot-requests completion", () => {
|
|
||||||
it("includes copilot-requests when allowCopilotRequestsPermission is enabled", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
permissions:
|
|
||||||
|`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
|
||||||
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: true})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
|
||||||
const labels = result.map(x => x.label);
|
|
||||||
expect(labels).toContain("actions");
|
|
||||||
expect(labels).toContain("copilot-requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes copilot-requests when allowCopilotRequestsPermission is disabled", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
permissions:
|
|
||||||
|`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
|
||||||
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: false})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
|
||||||
const labels = result.map(x => x.label);
|
|
||||||
expect(labels).toContain("actions");
|
|
||||||
expect(labels).not.toContain("copilot-requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes copilot-requests when no feature flags are provided", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
permissions:
|
|
||||||
|`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
|
||||||
const labels = result.map(x => x.label);
|
|
||||||
expect(labels).toContain("actions");
|
|
||||||
expect(labels).not.toContain("copilot-requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes copilot-requests in job-level permissions when allowCopilotRequestsPermission is enabled", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
|`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
|
||||||
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: true})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
|
||||||
const labels = result.map(x => x.label);
|
|
||||||
expect(labels).toContain("actions");
|
|
||||||
expect(labels).toContain("copilot-requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("excludes copilot-requests from job-level permissions when allowCopilotRequestsPermission is disabled", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
|`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {
|
|
||||||
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: false})
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
|
||||||
const labels = result.map(x => x.label);
|
|
||||||
expect(labels).toContain("actions");
|
|
||||||
expect(labels).not.toContain("copilot-requests");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("service container command/entrypoint completion", () => {
|
|
||||||
it("suggests entrypoint and command in service container", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis
|
|
||||||
|`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
|
||||||
const labels = result.map(x => x.label);
|
|
||||||
expect(labels).toContain("entrypoint");
|
|
||||||
expect(labels).toContain("command");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not suggest entrypoint and command in job container", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: node:20
|
|
||||||
|`;
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
|
||||||
const labels = result.map(x => x.label);
|
|
||||||
expect(labels).not.toContain("entrypoint");
|
|
||||||
expect(labels).not.toContain("command");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+113
-41
@@ -1,10 +1,8 @@
|
|||||||
import {complete as completeExpression, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||||
import {FunctionInfo} from "@actions/expressions/funcs/info";
|
|
||||||
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
||||||
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
|
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
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 {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
|
||||||
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
||||||
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
|
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
|
||||||
@@ -18,10 +16,8 @@ import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
|||||||
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
|
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
|
||||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
||||||
import {filterActionRunsCompletions, getActionScaffoldingSnippets} from "./complete-action.js";
|
|
||||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||||
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
|
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
|
||||||
import {getFunctionDescription} from "./context-providers/descriptions.js";
|
|
||||||
import {ActionContext, getActionContext} from "./context/action-context.js";
|
import {ActionContext, getActionContext} from "./context/action-context.js";
|
||||||
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
|
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
|
||||||
import {validatorFunctions} from "./expression-validation/functions.js";
|
import {validatorFunctions} from "./expression-validation/functions.js";
|
||||||
@@ -42,6 +38,24 @@ import {Value, ValueProviderConfig} from "./value-providers/config.js";
|
|||||||
import {defaultValueProviders} from "./value-providers/default.js";
|
import {defaultValueProviders} from "./value-providers/default.js";
|
||||||
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
|
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid keys for each action type under the `runs:` section.
|
||||||
|
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
|
||||||
|
*/
|
||||||
|
const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
|
||||||
|
const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]);
|
||||||
|
const ACTION_DOCKER_KEYS = new Set([
|
||||||
|
"using",
|
||||||
|
"image",
|
||||||
|
"args",
|
||||||
|
"env",
|
||||||
|
"entrypoint",
|
||||||
|
"pre-entrypoint",
|
||||||
|
"pre-if",
|
||||||
|
"post-entrypoint",
|
||||||
|
"post-if"
|
||||||
|
]);
|
||||||
|
|
||||||
export function getExpressionInput(input: string, pos: number): string {
|
export function getExpressionInput(input: string, pos: number): string {
|
||||||
// Find start marker around the cursor position
|
// Find start marker around the cursor position
|
||||||
let startPos = input.lastIndexOf(OPEN_EXPRESSION, pos);
|
let startPos = input.lastIndexOf(OPEN_EXPRESSION, pos);
|
||||||
@@ -58,7 +72,6 @@ export type CompletionConfig = {
|
|||||||
valueProviderConfig?: ValueProviderConfig;
|
valueProviderConfig?: ValueProviderConfig;
|
||||||
contextProviderConfig?: ContextProviderConfig;
|
contextProviderConfig?: ContextProviderConfig;
|
||||||
fileProvider?: FileProvider;
|
fileProvider?: FileProvider;
|
||||||
featureFlags?: FeatureFlags;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function complete(
|
export async function complete(
|
||||||
@@ -116,33 +129,31 @@ export async function complete(
|
|||||||
config,
|
config,
|
||||||
{
|
{
|
||||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||||
errorPolicy: ErrorPolicy.TryConversion,
|
errorPolicy: ErrorPolicy.TryConversion
|
||||||
featureFlags: config?.featureFlags
|
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
|
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate currentValue for value providers that need the query (e.g., step-uses marketplace search)
|
||||||
|
if (workflowContext && token && isString(token)) {
|
||||||
|
workflowContext.currentValue = token.value;
|
||||||
|
}
|
||||||
|
|
||||||
// Expression completions
|
// Expression completions
|
||||||
if (token && (isBasicExpression(token) || isPotentiallyExpression(token, isAction))) {
|
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
|
||||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||||
const {namedContexts, functions: extensionFunctions} = splitAllowedContext(allowedContext);
|
|
||||||
const context = isAction
|
const context = isAction
|
||||||
? getActionExpressionContext(namedContexts, config?.contextProviderConfig, actionContext, Mode.Completion)
|
? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion)
|
||||||
: await getWorkflowExpressionContext(
|
: await getWorkflowExpressionContext(
|
||||||
namedContexts,
|
allowedContext,
|
||||||
config?.contextProviderConfig,
|
config?.contextProviderConfig,
|
||||||
workflowContext,
|
workflowContext,
|
||||||
Mode.Completion
|
Mode.Completion
|
||||||
);
|
);
|
||||||
|
|
||||||
// Populate function descriptions for completion display
|
return getExpressionCompletionItems(token, context, newPos);
|
||||||
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
|
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
|
||||||
@@ -164,14 +175,6 @@ export async function complete(
|
|||||||
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
|
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter `copilot-requests` from permissions completions when the feature flag is disabled
|
|
||||||
if (
|
|
||||||
!config?.featureFlags?.isEnabled("allowCopilotRequestsPermission") &&
|
|
||||||
parent?.definition?.key === "permissions-mapping"
|
|
||||||
) {
|
|
||||||
values = values.filter(v => v.label !== "copilot-requests");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
|
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
|
||||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
||||||
values.push(...escapeHatches);
|
values.push(...escapeHatches);
|
||||||
@@ -203,14 +206,8 @@ 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
|
// Convert values to LSP CompletionItems
|
||||||
const completionItems = values.map(value => {
|
return values.map(value => {
|
||||||
const newText = value.insertText || value.label;
|
const newText = value.insertText || value.label;
|
||||||
|
|
||||||
// Escape hatches provide their own textEdit to restructure the YAML
|
// Escape hatches provide their own textEdit to restructure the YAML
|
||||||
@@ -232,6 +229,7 @@ export async function complete(
|
|||||||
const item: CompletionItem = {
|
const item: CompletionItem = {
|
||||||
label: value.label,
|
label: value.label,
|
||||||
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
|
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
|
||||||
|
kind: value.kind as CompletionItemKind,
|
||||||
filterText: value.filterText,
|
filterText: value.filterText,
|
||||||
sortText: value.sortText,
|
sortText: value.sortText,
|
||||||
documentation: value.description && {
|
documentation: value.description && {
|
||||||
@@ -245,9 +243,6 @@ export async function complete(
|
|||||||
|
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add action scaffolding snippets if available
|
|
||||||
return [...completionItems, ...actionSnippets];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -539,9 +534,7 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT
|
|||||||
function getExpressionCompletionItems(
|
function getExpressionCompletionItems(
|
||||||
token: TemplateToken,
|
token: TemplateToken,
|
||||||
context: DescriptionDictionary,
|
context: DescriptionDictionary,
|
||||||
extensionFunctions: FunctionInfo[],
|
pos: Position
|
||||||
pos: Position,
|
|
||||||
featureFlags?: FeatureFlags
|
|
||||||
): CompletionItem[] {
|
): CompletionItem[] {
|
||||||
if (!token.range) {
|
if (!token.range) {
|
||||||
return [];
|
return [];
|
||||||
@@ -560,8 +553,8 @@ function getExpressionCompletionItems(
|
|||||||
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return completeExpression(expressionInput, context, extensionFunctions, validatorFunctions, featureFlags).map(
|
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
|
||||||
item => mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
||||||
@@ -639,3 +632,82 @@ function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: P
|
|||||||
// = 32 + 11 = 43
|
// = 32 + 11 = 43
|
||||||
return lengthOfContentBeforeCurrentLine + pos.character;
|
return lengthOfContentBeforeCurrentLine + pos.character;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters action.yml `runs:` completions based on the `using:` value.
|
||||||
|
*
|
||||||
|
* When the user is completing keys under `runs:`:
|
||||||
|
* - If `using: node20` is set, only show Node.js action keys
|
||||||
|
* - If `using: composite` is set, only show composite action keys
|
||||||
|
* - If `using: docker` is set, only show Docker action keys
|
||||||
|
* - If `using:` is not set, show all keys but prioritize `using` first
|
||||||
|
*/
|
||||||
|
function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] {
|
||||||
|
// Find the runs mapping from the root
|
||||||
|
let runsMapping: MappingToken | undefined;
|
||||||
|
if (root instanceof MappingToken) {
|
||||||
|
for (let i = 0; i < root.count; i++) {
|
||||||
|
const {key, value} = root.get(i);
|
||||||
|
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
||||||
|
runsMapping = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!runsMapping) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the runs mapping is in our path (meaning we're completing inside it)
|
||||||
|
const isInsideRuns = path.some(token => token === runsMapping);
|
||||||
|
if (!isInsideRuns) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find where runsMapping is in the path
|
||||||
|
const runsMappingIndex = path.indexOf(runsMapping);
|
||||||
|
if (runsMappingIndex === -1) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's anything after runsMapping in the path
|
||||||
|
// If so, we're nested deeper (e.g., inside steps sequence or a step mapping)
|
||||||
|
if (runsMappingIndex < path.length - 1) {
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the using value from the runs mapping
|
||||||
|
let usingValue: string | undefined;
|
||||||
|
for (let i = 0; i < runsMapping.count; i++) {
|
||||||
|
const {key, value} = runsMapping.get(i);
|
||||||
|
if (key.toString().toLowerCase() === "using") {
|
||||||
|
usingValue = value.toString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which keys to allow
|
||||||
|
let allowedKeys: Set<string>;
|
||||||
|
|
||||||
|
if (!usingValue) {
|
||||||
|
// No using value set - show all keys but prioritize "using"
|
||||||
|
return values.map(v => {
|
||||||
|
if (v.label.toLowerCase() === "using") {
|
||||||
|
return {...v, sortText: "0_using"}; // Sort first
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
} else if (usingValue.match(/^node\d+$/i)) {
|
||||||
|
allowedKeys = ACTION_NODE_KEYS;
|
||||||
|
} else if (usingValue.toLowerCase() === "composite") {
|
||||||
|
allowedKeys = ACTION_COMPOSITE_KEYS;
|
||||||
|
} else if (usingValue.toLowerCase() === "docker") {
|
||||||
|
allowedKeys = ACTION_DOCKER_KEYS;
|
||||||
|
} else {
|
||||||
|
// Unknown using value - show all
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only allowed keys
|
||||||
|
return values.filter(v => allowedKeys.has(v.label.toLowerCase()));
|
||||||
|
}
|
||||||
|
|||||||
@@ -198,13 +198,9 @@ function getDefaultActionContext(
|
|||||||
case "runner":
|
case "runner":
|
||||||
return getRunnerContext();
|
return getRunnerContext();
|
||||||
|
|
||||||
case "env": {
|
case "env":
|
||||||
// Actions can access env but we don't know what env vars the calling workflow defines
|
// Actions can access env but we don't have runtime values
|
||||||
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
|
return new DescriptionDictionary();
|
||||||
const envContext = new DescriptionDictionary();
|
|
||||||
envContext.complete = false;
|
|
||||||
return envContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "job": {
|
case "job": {
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||||
@@ -222,13 +218,9 @@ function getDefaultActionContext(
|
|||||||
case "strategy":
|
case "strategy":
|
||||||
return getStrategyContext();
|
return getStrategyContext();
|
||||||
|
|
||||||
case "matrix": {
|
case "matrix":
|
||||||
// Actions can access matrix context at runtime but we don't know the calling workflow's matrix
|
// Actions can access matrix context at runtime
|
||||||
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
|
return new DescriptionDictionary();
|
||||||
const matrixContext = new DescriptionDictionary();
|
|
||||||
matrixContext.complete = false;
|
|
||||||
return matrixContext;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -105,6 +105,13 @@
|
|||||||
"job": {
|
"job": {
|
||||||
"description": "The [`job_id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_id) of the current job.\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
|
"description": "The [`job_id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_id) of the current job.\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
|
||||||
},
|
},
|
||||||
|
"job_workflow_sha": {
|
||||||
|
"description": "For jobs using a reusable workflow, the commit SHA for the reusable workflow file.",
|
||||||
|
"versions": {
|
||||||
|
"ghes": ">=3.9",
|
||||||
|
"ghae": ">=3.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"description": "Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path).\""
|
"description": "Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path).\""
|
||||||
},
|
},
|
||||||
@@ -218,18 +225,6 @@
|
|||||||
},
|
},
|
||||||
"check_run_id": {
|
"check_run_id": {
|
||||||
"description": "The unique identifier of the check run for this job."
|
"description": "The unique identifier of the check run for this job."
|
||||||
},
|
|
||||||
"workflow_file_path": {
|
|
||||||
"description": "The path of the workflow file that contains the job. For example, `.github/workflows/my-workflow.yml`."
|
|
||||||
},
|
|
||||||
"workflow_ref": {
|
|
||||||
"description": "The ref path to the workflow file that contains the job. For example, `octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch`."
|
|
||||||
},
|
|
||||||
"workflow_repository": {
|
|
||||||
"description": "The owner and repository name of the workflow file that contains the job. For example, `octocat/Hello-World`."
|
|
||||||
},
|
|
||||||
"workflow_sha": {
|
|
||||||
"description": "The commit SHA of the workflow file that contains the job."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"secrets": {
|
"secrets": {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
|
|||||||
"graphql_url",
|
"graphql_url",
|
||||||
"head_ref",
|
"head_ref",
|
||||||
"job",
|
"job",
|
||||||
|
"job_workflow_sha",
|
||||||
"path",
|
"path",
|
||||||
"ref",
|
"ref",
|
||||||
"ref_name",
|
"ref_name",
|
||||||
|
|||||||
@@ -18,16 +18,12 @@ describe("job context", () => {
|
|||||||
expect(context.pairs().length).toBe(0);
|
expect(context.pairs().length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns status, check_run_id, and workflow fields when job has no container or services", () => {
|
it("returns status and check_run_id when job has no container or services", () => {
|
||||||
const workflowContext = {job: {}} as WorkflowContext;
|
const workflowContext = {job: {}} as WorkflowContext;
|
||||||
const context = getJobContext(workflowContext);
|
const context = getJobContext(workflowContext);
|
||||||
|
|
||||||
expect(context.get("status")).toBeDefined();
|
expect(context.get("status")).toBeDefined();
|
||||||
expect(context.get("check_run_id")).toBeDefined();
|
expect(context.get("check_run_id")).toBeDefined();
|
||||||
expect(context.get("workflow_ref")).toBeDefined();
|
|
||||||
expect(context.get("workflow_sha")).toBeDefined();
|
|
||||||
expect(context.get("workflow_repository")).toBeDefined();
|
|
||||||
expect(context.get("workflow_file_path")).toBeDefined();
|
|
||||||
expect(context.get("container")).toBeUndefined();
|
expect(context.get("container")).toBeUndefined();
|
||||||
expect(context.get("services")).toBeUndefined();
|
expect(context.get("services")).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -177,21 +173,4 @@ describe("job context", () => {
|
|||||||
expect(redis.getDescription("ports")).toBeDefined();
|
expect(redis.getDescription("ports")).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workflow context fields", () => {
|
|
||||||
it("includes workflow context fields with descriptions", () => {
|
|
||||||
const workflowContext = {job: {}} as WorkflowContext;
|
|
||||||
const context = getJobContext(workflowContext);
|
|
||||||
|
|
||||||
expect(context.get("workflow_ref")).toBeDefined();
|
|
||||||
expect(context.get("workflow_sha")).toBeDefined();
|
|
||||||
expect(context.get("workflow_repository")).toBeDefined();
|
|
||||||
expect(context.get("workflow_file_path")).toBeDefined();
|
|
||||||
|
|
||||||
expect(context.getDescription("workflow_ref")).toBeDefined();
|
|
||||||
expect(context.getDescription("workflow_sha")).toBeDefined();
|
|
||||||
expect(context.getDescription("workflow_repository")).toBeDefined();
|
|
||||||
expect(context.getDescription("workflow_file_path")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {WorkflowContext} from "../context/workflow-context.js";
|
|||||||
import {getDescription} from "./descriptions.js";
|
import {getDescription} from "./descriptions.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the job context with container, services, status, check_run_id, and workflow identity fields.
|
* Returns the job context with container, services, status, and check_run_id.
|
||||||
*/
|
*/
|
||||||
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||||
@@ -42,12 +42,6 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
|||||||
// Check run ID
|
// Check run ID
|
||||||
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
|
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
|
||||||
|
|
||||||
// Workflow context fields (populated at runtime for reusable workflow jobs)
|
|
||||||
jobContext.add("workflow_file_path", new data.StringData(""), getDescription("job", "workflow_file_path"));
|
|
||||||
jobContext.add("workflow_ref", new data.StringData(""), getDescription("job", "workflow_ref"));
|
|
||||||
jobContext.add("workflow_repository", new data.StringData(""), getDescription("job", "workflow_repository"));
|
|
||||||
jobContext.add("workflow_sha", new data.StringData(""), getDescription("job", "workflow_sha"));
|
|
||||||
|
|
||||||
return jobContext;
|
return jobContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export interface WorkflowContext {
|
|||||||
|
|
||||||
/** If the context is for a position within a step, this will be the step */
|
/** If the context is for a position within a step, this will be the step */
|
||||||
step?: Step;
|
step?: Step;
|
||||||
|
|
||||||
|
/** The current value being typed at the cursor position (for value providers that need the query) */
|
||||||
|
currentValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -120,9 +120,7 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
const result = await hover(...getPositionFromCursor(input));
|
const result = await hover(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result?.contents).toEqual(
|
expect(result?.contents).toEqual("");
|
||||||
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("on an invalid cron schedule", async () => {
|
it("on an invalid cron schedule", async () => {
|
||||||
@@ -132,9 +130,7 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
const result = await hover(...getPositionFromCursor(input));
|
const result = await hover(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result?.contents).toEqual(
|
expect(result?.contents).toEqual("");
|
||||||
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows context inherited from parent nodes", async () => {
|
it("shows context inherited from parent nodes", async () => {
|
||||||
@@ -199,7 +195,7 @@ jobs:
|
|||||||
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "step-uses", undefined));
|
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "step-uses", undefined));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result?.contents).toEqual(
|
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, 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."
|
"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."
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export async function hover(document: TextDocument, position: Position, config?:
|
|||||||
// Early exit if there's nothing to provide hover for
|
// Early exit if there's nothing to provide hover for
|
||||||
const hoverToken = token || keyToken;
|
const hoverToken = token || keyToken;
|
||||||
const isExpressionHover =
|
const isExpressionHover =
|
||||||
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token, isAction));
|
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token));
|
||||||
if (!isExpressionHover && !hoverToken?.definition) {
|
if (!isExpressionHover && !hoverToken?.definition) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export {complete, CompletionConfig} from "./complete.js";
|
export {complete} from "./complete.js";
|
||||||
export {ContextProviderConfig} from "./context-providers/config.js";
|
export {ContextProviderConfig} from "./context-providers/config.js";
|
||||||
export {documentLinks} from "./document-links.js";
|
export {documentLinks} from "./document-links.js";
|
||||||
export {hover} from "./hover.js";
|
export {hover} from "./hover.js";
|
||||||
@@ -6,4 +6,3 @@ export {getInlayHints} from "./inlay-hints.js";
|
|||||||
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js";
|
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js";
|
||||||
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js";
|
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js";
|
||||||
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||||
export {getCodeActions, CodeActionParams} from "./code-actions/code-actions.js";
|
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
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,36 +2,10 @@ import {isString} from "@actions/workflow-parser";
|
|||||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||||
|
|
||||||
/**
|
export function isPotentiallyExpression(token: TemplateToken): boolean {
|
||||||
* Workflow schema if-condition definition keys.
|
const containsExpression = isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0;
|
||||||
* - job-if: job level if condition
|
// If conditions are always expressions (job-if, step-if, snapshot-if)
|
||||||
* - step-if: step level if condition
|
const definitionKey = token.definition?.key;
|
||||||
* - snapshot-if: snapshot if condition
|
const isIfCondition = definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if";
|
||||||
*/
|
return containsExpression || isIfCondition;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,21 +249,7 @@ jobs:
|
|||||||
line: 7
|
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -308,25 +294,7 @@ jobs:
|
|||||||
line: 7
|
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -355,25 +323,7 @@ jobs:
|
|||||||
line: 6
|
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,22 +4,10 @@ import {Step} from "@actions/workflow-parser/model/workflow-template";
|
|||||||
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
import {ActionReference, parseActionReference} from "./action.js";
|
import {parseActionReference} from "./action.js";
|
||||||
import {mapRange} from "./utils/range.js";
|
import {mapRange} from "./utils/range.js";
|
||||||
import {ValidationConfig} from "./validate.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.
|
* Validates action references in workflow steps, checking for valid inputs and required inputs.
|
||||||
*/
|
*/
|
||||||
@@ -106,22 +94,10 @@ export async function validateActionReference(
|
|||||||
missingRequiredInputs.length === 1
|
missingRequiredInputs.length === 1
|
||||||
? `Missing required input \`${missingRequiredInputs[0][0]}\``
|
? `Missing required input \`${missingRequiredInputs[0][0]}\``
|
||||||
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
|
: `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({
|
diagnostics.push({
|
||||||
severity: DiagnosticSeverity.Error,
|
severity: DiagnosticSeverity.Error,
|
||||||
range: mapRange((withKey || stepToken).range),
|
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
|
||||||
message: message,
|
message: message
|
||||||
code: DiagnosticCode.MissingRequiredInputs,
|
|
||||||
data: diagnosticData
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -527,739 +527,4 @@ runs:
|
|||||||
expect(diagnostics.some(d => d.message.includes("is not valid for"))).toBe(false);
|
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,31 +2,20 @@
|
|||||||
* Validation for action.yml / action.yaml manifest files
|
* Validation for action.yml / action.yaml manifest files
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Lexer, Parser} from "@actions/expressions";
|
import {isMapping} from "@actions/workflow-parser";
|
||||||
import {Expr} from "@actions/expressions/ast";
|
|
||||||
import {isMapping, isString} from "@actions/workflow-parser";
|
|
||||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
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 {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-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 {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 {TemplateValidationError} from "@actions/workflow-parser/templates/template-validation-error";
|
||||||
import {File} from "@actions/workflow-parser/workflows/file";
|
import {File} from "@actions/workflow-parser/workflows/file";
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
import {error} from "./log.js";
|
import {error} from "./log.js";
|
||||||
import {mapRange} from "./utils/range.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 {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
|
||||||
import {validateActionReference} from "./validate-action-reference.js";
|
import {validateActionReference} from "./validate-action-reference.js";
|
||||||
import {validateFormatCalls} from "./validate-format-string.js";
|
|
||||||
import {ValidationConfig} from "./validate.js";
|
import {ValidationConfig} from "./validate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,15 +65,7 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the action template (this may add validation errors for pre-if/post-if)
|
// Get schema errors
|
||||||
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();
|
const schemaErrors = result.context.errors.getErrors();
|
||||||
|
|
||||||
// Run custom runs key validation, which also filters redundant schema errors in place
|
// Run custom runs key validation, which also filters redundant schema errors in place
|
||||||
@@ -112,9 +93,13 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate composite action steps if we have a parsed result
|
// Validate composite action steps if we have a parsed result
|
||||||
if (result.value && template) {
|
if (result.value) {
|
||||||
|
const template = getOrConvertActionTemplate(result.context, result.value, textDocument.uri, {
|
||||||
|
errorPolicy: ErrorPolicy.TryConversion
|
||||||
|
});
|
||||||
|
|
||||||
// Only composite actions have steps to validate
|
// Only composite actions have steps to validate
|
||||||
if (template.runs?.using === "composite") {
|
if (template?.runs?.using === "composite") {
|
||||||
const steps = template.runs.steps ?? [];
|
const steps = template.runs.steps ?? [];
|
||||||
|
|
||||||
// Find the steps sequence token from the raw parsed result
|
// Find the steps sequence token from the raw parsed result
|
||||||
@@ -129,18 +114,10 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
|||||||
if (isActionStep(step) && isMapping(stepToken)) {
|
if (isActionStep(step) && isMapping(stepToken)) {
|
||||||
await validateActionReference(diagnostics, stepToken, step, config);
|
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) {
|
} catch (e) {
|
||||||
error(`Unhandled error while validating action file: ${(e as Error).message}`);
|
error(`Unhandled error while validating action file: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -148,196 +125,6 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
|||||||
return diagnostics;
|
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.
|
* Find the steps sequence token from the raw action template.
|
||||||
* Traverses the token tree looking for the "composite-steps" definition.
|
* Traverses the token tree looking for the "composite-steps" definition.
|
||||||
|
|||||||
@@ -1,835 +0,0 @@
|
|||||||
import {FeatureFlags} from "@actions/expressions";
|
|
||||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
|
||||||
import {registerLogger} from "./log.js";
|
|
||||||
import {createDocument} from "./test-utils/document.js";
|
|
||||||
import {TestLogger} from "./test-utils/logger.js";
|
|
||||||
import {clearCache} from "./utils/workflow-cache.js";
|
|
||||||
import {validate, ValidationConfig} from "./validate.js";
|
|
||||||
|
|
||||||
registerLogger(new TestLogger());
|
|
||||||
|
|
||||||
const configWithFlag: ValidationConfig = {
|
|
||||||
featureFlags: new FeatureFlags({blockScalarChompingWarning: true})
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clearCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("block scalar chomping - warning cases", () => {
|
|
||||||
describe("step-level env values", () => {
|
|
||||||
it("warns with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo $VAR
|
|
||||||
env:
|
|
||||||
VAR: |
|
|
||||||
\${{ matrix.value }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn with keep chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo $VAR
|
|
||||||
env:
|
|
||||||
VAR: |+
|
|
||||||
\${{ matrix.value }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn with strip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo $VAR
|
|
||||||
env:
|
|
||||||
VAR: |-
|
|
||||||
\${{ matrix.value }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses > indicator in warning message for folded scalars", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo $VAR
|
|
||||||
env:
|
|
||||||
VAR: >
|
|
||||||
\${{ matrix.value }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '>' implicitly adds a trailing newline that may be unintentional. Use '>-' to remove it, or '>+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns for plain string env value with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo $VAR
|
|
||||||
env:
|
|
||||||
VAR: |
|
|
||||||
hello world
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("job-level env values", () => {
|
|
||||||
it("warns with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
MY_VAR: |
|
|
||||||
some value
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("workflow-level env values", () => {
|
|
||||||
it("warns with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
env:
|
|
||||||
GLOBAL_VAR: |
|
|
||||||
some value
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("container env values", () => {
|
|
||||||
it("warns with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: node:18
|
|
||||||
env:
|
|
||||||
CONTAINER_VAR: |
|
|
||||||
some value
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("service container env values", () => {
|
|
||||||
it("warns with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis
|
|
||||||
env:
|
|
||||||
REDIS_PASSWORD: |
|
|
||||||
secret123
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("action input (with)", () => {
|
|
||||||
it("warns with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
\${{ matrix.value }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn with keep chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
script: |+
|
|
||||||
\${{ matrix.value }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn with strip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
script: |-
|
|
||||||
\${{ matrix.value }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reusable workflow inputs (with)", () => {
|
|
||||||
it("warns with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
call-workflow:
|
|
||||||
uses: ./.github/workflows/reusable.yml
|
|
||||||
with:
|
|
||||||
my-input: |
|
|
||||||
some value
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("reusable workflow secrets", () => {
|
|
||||||
it("warns with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
call-workflow:
|
|
||||||
uses: ./.github/workflows/reusable.yml
|
|
||||||
secrets:
|
|
||||||
my-secret: |
|
|
||||||
\${{ secrets.TOKEN }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("job outputs", () => {
|
|
||||||
it("warns with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
my_output: |
|
|
||||||
\${{ steps.test.outputs.value }}
|
|
||||||
steps:
|
|
||||||
- id: test
|
|
||||||
run: echo "value=test" >> $GITHUB_OUTPUT
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn with strip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
my_output: |-
|
|
||||||
\${{ steps.test.outputs.value }}
|
|
||||||
steps:
|
|
||||||
- id: test
|
|
||||||
run: echo "value=test" >> $GITHUB_OUTPUT
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("matrix values", () => {
|
|
||||||
it("warns for matrix vector value with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
config:
|
|
||||||
- |
|
|
||||||
value1
|
|
||||||
- value2
|
|
||||||
steps:
|
|
||||||
- run: echo \${{ matrix.config }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn with strip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
config:
|
|
||||||
- |-
|
|
||||||
value1
|
|
||||||
- value2
|
|
||||||
steps:
|
|
||||||
- run: echo \${{ matrix.config }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns for matrix include value with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest]
|
|
||||||
include:
|
|
||||||
- os: |
|
|
||||||
windows-latest
|
|
||||||
special: true
|
|
||||||
steps:
|
|
||||||
- run: echo \${{ matrix.os }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns for matrix exclude value with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, windows-latest]
|
|
||||||
node: [16, 18]
|
|
||||||
exclude:
|
|
||||||
- os: |
|
|
||||||
windows-latest
|
|
||||||
node: 16
|
|
||||||
steps:
|
|
||||||
- run: echo \${{ matrix.os }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns for deeply nested matrix value with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
config:
|
|
||||||
- foo:
|
|
||||||
bar: |
|
|
||||||
baz
|
|
||||||
steps:
|
|
||||||
- run: echo \${{ matrix.config }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns for deeply nested matrix include value with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest]
|
|
||||||
include:
|
|
||||||
- os: ubuntu-latest
|
|
||||||
config:
|
|
||||||
nested: |
|
|
||||||
value
|
|
||||||
steps:
|
|
||||||
- run: echo \${{ matrix.config }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns for deeply nested matrix exclude value with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, windows-latest]
|
|
||||||
exclude:
|
|
||||||
- os: windows-latest
|
|
||||||
config:
|
|
||||||
nested: |
|
|
||||||
value
|
|
||||||
steps:
|
|
||||||
- run: echo \${{ matrix.os }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("concurrency", () => {
|
|
||||||
it("warns for concurrency string with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency: |
|
|
||||||
my-group-\${{ github.ref }}
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn for concurrency with strip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency: |-
|
|
||||||
my-group-\${{ github.ref }}
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns for concurrency.group with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency:
|
|
||||||
group: |
|
|
||||||
my-group-\${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("warns for job-level concurrency with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
concurrency: |
|
|
||||||
job-group-\${{ github.ref }}
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
message:
|
|
||||||
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
|
|
||||||
code: "block-scalar-chomping",
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("block scalar chomping - no warning cases", () => {
|
|
||||||
describe("fields trimmed server-side", () => {
|
|
||||||
it("does not warn for job-if with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: |
|
|
||||||
github.ref == 'refs/heads/main'
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn for step-if with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
if: |
|
|
||||||
github.ref == 'refs/heads/main'
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn for runs-on with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: |
|
|
||||||
ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn for job name with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: |
|
|
||||||
My Job
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn for step name with clip chomping", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: |
|
|
||||||
My Step
|
|
||||||
run: echo done
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("run field (intentionally allowed)", () => {
|
|
||||||
it("does not warn for step run field", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: |
|
|
||||||
echo hello
|
|
||||||
echo world
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn for run field with expression", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: |
|
|
||||||
echo \${{ github.ref }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("non-block scalars", () => {
|
|
||||||
it("does not warn for quoted strings", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo $VAR
|
|
||||||
env:
|
|
||||||
VAR: "hello world"
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn for flow scalars", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo $VAR
|
|
||||||
env:
|
|
||||||
VAR: hello world
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not warn for inline expressions", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo $VAR
|
|
||||||
env:
|
|
||||||
VAR: \${{ matrix.value }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
|
||||||
|
|
||||||
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import {FeatureFlags} from "@actions/expressions/features";
|
|
||||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
import {validate} from "./validate.js";
|
import {validate} from "./validate.js";
|
||||||
import {createDocument} from "./test-utils/document.js";
|
import {createDocument} from "./test-utils/document.js";
|
||||||
@@ -8,10 +7,6 @@ beforeEach(() => {
|
|||||||
clearCache();
|
clearCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
const queueValidationConfig = {
|
|
||||||
featureFlags: new FeatureFlags({allowConcurrencyQueue: true})
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("validate concurrency deadlock", () => {
|
describe("validate concurrency deadlock", () => {
|
||||||
describe("should error on matching concurrency groups", () => {
|
describe("should error on matching concurrency groups", () => {
|
||||||
it("simple string match", async () => {
|
it("simple string match", async () => {
|
||||||
@@ -248,186 +243,3 @@ jobs:
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("validate concurrency queue + cancel-in-progress conflict", () => {
|
|
||||||
describe("should error", () => {
|
|
||||||
it("workflow-level queue: max with cancel-in-progress: true", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
cancel-in-progress: true
|
|
||||||
queue: max
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi`;
|
|
||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
|
|
||||||
|
|
||||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
|
||||||
expect(queueErrors).toHaveLength(1);
|
|
||||||
expect(queueErrors[0]).toMatchObject({
|
|
||||||
message: "'queue: max' cannot be combined with 'cancel-in-progress: true'.",
|
|
||||||
severity: DiagnosticSeverity.Error
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("job-level queue: max with cancel-in-progress: true", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
cancel-in-progress: true
|
|
||||||
queue: max
|
|
||||||
steps:
|
|
||||||
- run: echo hi`;
|
|
||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
|
|
||||||
|
|
||||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
|
||||||
expect(queueErrors).toHaveLength(1);
|
|
||||||
expect(queueErrors[0]).toMatchObject({
|
|
||||||
severity: DiagnosticSeverity.Error
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("both workflow and job level have the conflict", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
cancel-in-progress: true
|
|
||||||
queue: max
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
concurrency:
|
|
||||||
group: build
|
|
||||||
cancel-in-progress: true
|
|
||||||
queue: max
|
|
||||||
steps:
|
|
||||||
- run: echo hi`;
|
|
||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
|
|
||||||
|
|
||||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
|
||||||
expect(queueErrors).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("should not error", () => {
|
|
||||||
it("queue: max without cancel-in-progress", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
queue: max
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi`;
|
|
||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
|
|
||||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
|
||||||
expect(queueErrors).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("queue: single with cancel-in-progress: true", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
cancel-in-progress: true
|
|
||||||
queue: single
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi`;
|
|
||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
|
|
||||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
|
||||||
expect(queueErrors).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancel-in-progress: false with queue: max", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
cancel-in-progress: false
|
|
||||||
queue: max
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi`;
|
|
||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
|
|
||||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
|
||||||
expect(queueErrors).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("no queue property", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
cancel-in-progress: true
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi`;
|
|
||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
|
|
||||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
|
||||||
expect(queueErrors).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("string form concurrency (no mapping)", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency: deploy
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi`;
|
|
||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
|
|
||||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
|
||||||
expect(queueErrors).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not report queue conflict when the feature is disabled", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
cancel-in-progress: true
|
|
||||||
queue: max
|
|
||||||
jobs:
|
|
||||||
job1:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi`;
|
|
||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
|
|
||||||
const queueConflictErrors = result.filter(d => d.message.includes("queue: max"));
|
|
||||||
expect(queueConflictErrors).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -160,21 +160,6 @@ 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", () => {
|
describe("snapshot-if", () => {
|
||||||
|
|||||||
@@ -432,24 +432,6 @@ jobs:
|
|||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("job.workflow_* fields", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo \${{ job.workflow_ref }}
|
|
||||||
- run: echo \${{ job.workflow_sha }}
|
|
||||||
- run: echo \${{ job.workflow_repository }}
|
|
||||||
- run: echo \${{ job.workflow_file_path }}
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("job.services.<service_id>", async () => {
|
it("job.services.<service_id>", async () => {
|
||||||
const input = `
|
const input = `
|
||||||
on: push
|
on: push
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import {registerLogger} from "./log.js";
|
|
||||||
import {createDocument} from "./test-utils/document.js";
|
|
||||||
import {TestLogger} from "./test-utils/logger.js";
|
|
||||||
import {clearCache} from "./utils/workflow-cache.js";
|
|
||||||
import {validate} from "./validate.js";
|
|
||||||
|
|
||||||
registerLogger(new TestLogger());
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clearCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("service container command/entrypoint", () => {
|
|
||||||
it("allows command in service container", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis
|
|
||||||
command: --port 6380
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
const commandErrors = result.filter(d => d.message.includes("command"));
|
|
||||||
expect(commandErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows entrypoint in service container", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis
|
|
||||||
entrypoint: /usr/local/bin/redis-server
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
|
|
||||||
expect(entrypointErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows both command and entrypoint in service container", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
services:
|
|
||||||
redis:
|
|
||||||
image: redis
|
|
||||||
entrypoint: /usr/local/bin/redis-server
|
|
||||||
command: --port 6380
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
const relevantErrors = result.filter(d => d.message.includes("command") || d.message.includes("entrypoint"));
|
|
||||||
expect(relevantErrors).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects command in job container", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: node:20
|
|
||||||
command: node
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
const commandErrors = result.filter(d => d.message.includes("command"));
|
|
||||||
expect(commandErrors.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects entrypoint in job container", async () => {
|
|
||||||
const input = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: node:20
|
|
||||||
entrypoint: /bin/bash
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
|
|
||||||
expect(entrypointErrors.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -368,24 +368,6 @@ jobs:
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("environment deployment", () => {
|
|
||||||
it("allows deployment boolean under environment mapping", async () => {
|
|
||||||
const workflow = `
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment:
|
|
||||||
name: prod
|
|
||||||
deployment: false
|
|
||||||
steps:
|
|
||||||
- run: echo
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", workflow));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("workflow_dispatch", () => {
|
describe("workflow_dispatch", () => {
|
||||||
it("allows empty string in choice options", async () => {
|
it("allows empty string in choice options", async () => {
|
||||||
const result = await validate(
|
const result = await validate(
|
||||||
|
|||||||
+177
-169
@@ -1,13 +1,6 @@
|
|||||||
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
|
import {Lexer, Parser, data} from "@actions/expressions";
|
||||||
import {Expr} from "@actions/expressions/ast";
|
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||||
import {
|
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||||
TemplateParseResult,
|
|
||||||
WorkflowTemplate,
|
|
||||||
isBasicExpression,
|
|
||||||
isBoolean,
|
|
||||||
isMapping,
|
|
||||||
isString
|
|
||||||
} from "@actions/workflow-parser";
|
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
||||||
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||||
@@ -31,8 +24,6 @@ import {error} from "./log.js";
|
|||||||
import {isActionDocument} from "./utils/document-type.js";
|
import {isActionDocument} from "./utils/document-type.js";
|
||||||
import {findToken} from "./utils/find-token.js";
|
import {findToken} from "./utils/find-token.js";
|
||||||
import {mapRange} from "./utils/range.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 {getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||||
import {validateActionReference} from "./validate-action-reference.js";
|
import {validateActionReference} from "./validate-action-reference.js";
|
||||||
import {validateAction} from "./validate-action.js";
|
import {validateAction} from "./validate-action.js";
|
||||||
@@ -48,7 +39,6 @@ export type ValidationConfig = {
|
|||||||
contextProviderConfig?: ContextProviderConfig;
|
contextProviderConfig?: ContextProviderConfig;
|
||||||
actionsMetadataProvider?: ActionsMetadataProvider;
|
actionsMetadataProvider?: ActionsMetadataProvider;
|
||||||
fileProvider?: FileProvider;
|
fileProvider?: FileProvider;
|
||||||
featureFlags?: FeatureFlags;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActionsMetadataProvider = {
|
export type ActionsMetadataProvider = {
|
||||||
@@ -91,12 +81,11 @@ async function validateWorkflow(textDocument: TextDocument, config?: ValidationC
|
|||||||
// Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information
|
// Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information
|
||||||
const template = await getOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
|
const template = await getOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
|
||||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||||
errorPolicy: ErrorPolicy.TryConversion,
|
errorPolicy: ErrorPolicy.TryConversion
|
||||||
featureFlags: config?.featureFlags
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate expressions and value providers
|
// Validate expressions and value providers
|
||||||
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config, config?.featureFlags);
|
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For now map parser errors directly to diagnostics
|
// For now map parser errors directly to diagnostics
|
||||||
@@ -120,10 +109,9 @@ async function additionalValidations(
|
|||||||
documentUri: URI,
|
documentUri: URI,
|
||||||
template: WorkflowTemplate,
|
template: WorkflowTemplate,
|
||||||
root: TemplateToken,
|
root: TemplateToken,
|
||||||
config?: ValidationConfig,
|
config?: ValidationConfig
|
||||||
featureFlags?: FeatureFlags
|
|
||||||
) {
|
) {
|
||||||
for (const [parent, token, key, ancestors] of TemplateToken.traverse(root)) {
|
for (const [parent, token, key] of TemplateToken.traverse(root)) {
|
||||||
// If the token is a value in a pair, use the key definition for validation
|
// If the token is a value in a pair, use the key definition for validation
|
||||||
// If the token has a parent (map, sequence, etc), use this definition for validation
|
// If the token has a parent (map, sequence, etc), use this definition for validation
|
||||||
const validationToken = key || parent || token;
|
const validationToken = key || parent || token;
|
||||||
@@ -141,12 +129,7 @@ async function additionalValidations(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate block scalar chomping for expressions and strings
|
// If this is a job-if, step-if, or snapshot-if field (which are strings that should be treated as expressions), validate it
|
||||||
if (featureFlags?.isEnabled("blockScalarChompingWarning")) {
|
|
||||||
validateBlockScalarChomping(diagnostics, token, parent, key, ancestors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `if` conditions allow omitting ${{ }}, so validate strings in these fields as expressions
|
|
||||||
const definitionKey = token.definition?.key;
|
const definitionKey = token.definition?.key;
|
||||||
if (
|
if (
|
||||||
isString(token) &&
|
isString(token) &&
|
||||||
@@ -166,9 +149,7 @@ async function additionalValidations(
|
|||||||
finalCondition,
|
finalCondition,
|
||||||
token.definitionInfo,
|
token.definitionInfo,
|
||||||
undefined,
|
undefined,
|
||||||
token.source,
|
token.source
|
||||||
undefined,
|
|
||||||
token.blockScalarHeader
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await validateExpression(
|
await validateExpression(
|
||||||
@@ -246,11 +227,6 @@ async function additionalValidations(
|
|||||||
|
|
||||||
// Validate concurrency deadlock between workflow and job levels
|
// Validate concurrency deadlock between workflow and job levels
|
||||||
validateConcurrencyDeadlock(diagnostics, template);
|
validateConcurrencyDeadlock(diagnostics, template);
|
||||||
|
|
||||||
// Validate incompatible concurrency options
|
|
||||||
if (featureFlags?.isEnabled("allowConcurrencyQueue")) {
|
|
||||||
validateConcurrencyQueueCancelInProgress(diagnostics, template);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
|
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
|
||||||
@@ -300,6 +276,116 @@ 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).
|
* Validates the format of a job's `uses` field (reusable workflow reference).
|
||||||
*
|
*
|
||||||
@@ -544,6 +630,64 @@ function getProviderContext(
|
|||||||
return getWorkflowContext(documentUri, template, path);
|
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(
|
async function validateExpression(
|
||||||
diagnostics: Diagnostic[],
|
diagnostics: Diagnostic[],
|
||||||
token: BasicExpressionToken,
|
token: BasicExpressionToken,
|
||||||
@@ -676,55 +820,6 @@ function validateConcurrencyDeadlock(diagnostics: Diagnostic[], template: Workfl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that `queue: max` and `cancel-in-progress: true` are not both set
|
|
||||||
* in a concurrency mapping, as this combination is invalid.
|
|
||||||
*/
|
|
||||||
function validateConcurrencyQueueCancelInProgress(diagnostics: Diagnostic[], template: WorkflowTemplate): void {
|
|
||||||
// Check workflow-level concurrency
|
|
||||||
if (template.concurrency) {
|
|
||||||
checkConcurrencyQueueConflict(diagnostics, template.concurrency);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check job-level concurrency
|
|
||||||
for (const job of template.jobs || []) {
|
|
||||||
if (job.concurrency) {
|
|
||||||
checkConcurrencyQueueConflict(diagnostics, job.concurrency);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkConcurrencyQueueConflict(diagnostics: Diagnostic[], token: TemplateToken): void {
|
|
||||||
if (!isMapping(token)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasQueueMax = false;
|
|
||||||
let hasCancelInProgressTrue = false;
|
|
||||||
let queueRange: TokenRange | undefined;
|
|
||||||
|
|
||||||
for (const pair of token) {
|
|
||||||
if (!isString(pair.key) || pair.key.isExpression || pair.value.isExpression) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (pair.key.value === "queue" && isString(pair.value) && pair.value.value === "max") {
|
|
||||||
hasQueueMax = true;
|
|
||||||
queueRange = pair.key.range;
|
|
||||||
}
|
|
||||||
if (pair.key.value === "cancel-in-progress" && isBoolean(pair.value) && pair.value.value) {
|
|
||||||
hasCancelInProgressTrue = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasQueueMax && hasCancelInProgressTrue && queueRange) {
|
|
||||||
diagnostics.push({
|
|
||||||
message: "'queue: max' cannot be combined with 'cancel-in-progress: true'.",
|
|
||||||
range: mapRange(queueRange),
|
|
||||||
severity: DiagnosticSeverity.Error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the static concurrency group name from a concurrency token.
|
* Extracts the static concurrency group name from a concurrency token.
|
||||||
* Returns undefined if the token is an expression or doesn't have a static group.
|
* Returns undefined if the token is an expression or doesn't have a static group.
|
||||||
@@ -750,90 +845,3 @@ function getStaticConcurrencyGroup(token: TemplateToken | undefined): StringToke
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates YAML block scalar chomping.
|
|
||||||
*
|
|
||||||
* Block scalars (| and >) implicitly add a trailing newline by default ("clip" chomping).
|
|
||||||
* This is often unintended by the workflow author and can cause unexpected behavior.
|
|
||||||
* This function warns on certain fields when clip chomping is used (implicit trailing newline)
|
|
||||||
* and suggests they explicitly use strip (|-) or keep (|+) to clarify intent.
|
|
||||||
*
|
|
||||||
* Only specific fields are validated - those where trailing newlines may cause
|
|
||||||
* issues but aren't automatically trimmed server-side. For example env, inputs, outputs, etc.
|
|
||||||
*
|
|
||||||
* Skipped fields:
|
|
||||||
* - run: Multi-line scripts commonly have trailing newlines
|
|
||||||
* - Fields trimmed server-side: name, uses, shell, if, etc.
|
|
||||||
*/
|
|
||||||
function validateBlockScalarChomping(
|
|
||||||
diagnostics: Diagnostic[],
|
|
||||||
token: TemplateToken,
|
|
||||||
parent: TemplateToken | undefined,
|
|
||||||
key: TemplateToken | undefined,
|
|
||||||
ancestors: TemplateToken[]
|
|
||||||
): void {
|
|
||||||
// Not an expression or string?
|
|
||||||
if (!isBasicExpression(token) && !isString(token)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not a block scalar?
|
|
||||||
const header = token.blockScalarHeader;
|
|
||||||
if (!header) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not "clip" chomp style?
|
|
||||||
if (header.includes("+") || header.includes("-")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should warn
|
|
||||||
let shouldWarn = false;
|
|
||||||
const parentDefinitionName = parent?.definition?.key;
|
|
||||||
const tokenDefinitionName = token.definition?.key;
|
|
||||||
const keyName = key && isString(key) ? key.value : undefined;
|
|
||||||
if (
|
|
||||||
parentDefinitionName &&
|
|
||||||
[
|
|
||||||
"workflow-env",
|
|
||||||
"job-env",
|
|
||||||
"step-env",
|
|
||||||
"container-env",
|
|
||||||
"step-with",
|
|
||||||
"job-outputs",
|
|
||||||
"workflow-job-with",
|
|
||||||
"workflow-job-secrets"
|
|
||||||
].includes(parentDefinitionName)
|
|
||||||
) {
|
|
||||||
// env, with, outputs, or secrets fields
|
|
||||||
shouldWarn = true;
|
|
||||||
} else if (
|
|
||||||
ancestors.some(ancestor => {
|
|
||||||
const ancestorKey = ancestor.definition?.key;
|
|
||||||
return ancestorKey === "matrix" || ancestorKey === "matrix-filter" || ancestorKey === "matrix-filter-item";
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
// Matrix values (vectors, include, exclude)
|
|
||||||
shouldWarn = true;
|
|
||||||
} else if (tokenDefinitionName && ["workflow-concurrency", "job-concurrency"].includes(tokenDefinitionName)) {
|
|
||||||
// Concurrency shorthand
|
|
||||||
shouldWarn = true;
|
|
||||||
} else if (keyName === "group" && parentDefinitionName === "concurrency-mapping") {
|
|
||||||
// Concurrency group field
|
|
||||||
shouldWarn = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldWarn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockIndicator = header.startsWith("|") ? "|" : ">";
|
|
||||||
diagnostics.push({
|
|
||||||
message: `Block scalar '${blockIndicator}' implicitly adds a trailing newline that may be unintentional. Use '${blockIndicator}-' to remove it, or '${blockIndicator}+' to explicitly keep it.`,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
severity: DiagnosticSeverity.Warning,
|
|
||||||
code: "block-scalar-chomping"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
expect(result).toContainEqual({
|
expect(result).toContainEqual({
|
||||||
message: "'uses' value in action cannot be blank",
|
message: "`uses' value in action cannot be blank",
|
||||||
severity: DiagnosticSeverity.Error,
|
severity: DiagnosticSeverity.Error,
|
||||||
range: {
|
range: {
|
||||||
start: {line: 5, character: 12},
|
start: {line: 5, character: 12},
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface Value {
|
|||||||
/** Sort text to control ordering, if not given `label` will be used for sorting */
|
/** Sort text to control ordering, if not given `label` will be used for sorting */
|
||||||
sortText?: string;
|
sortText?: string;
|
||||||
|
|
||||||
|
/** CompletionItemKind (as number to avoid LSP dependency). E.g., 9 = Module */
|
||||||
|
kind?: number;
|
||||||
|
|
||||||
/** Custom text edit with specific range, overrides default range calculation */
|
/** Custom text edit with specific range, overrides default range calculation */
|
||||||
textEdit?: {
|
textEdit?: {
|
||||||
range: {start: {line: number; character: number}; end: {line: number; character: number}};
|
range: {start: {line: number; character: number}; end: {line: number; character: number}};
|
||||||
|
|||||||
@@ -4,36 +4,19 @@ import {reusableJobInputs} from "./reusable-job-inputs.js";
|
|||||||
import {reusableJobSecrets} from "./reusable-job-secrets.js";
|
import {reusableJobSecrets} from "./reusable-job-secrets.js";
|
||||||
import {stringsToValues} from "./strings-to-values.js";
|
import {stringsToValues} from "./strings-to-values.js";
|
||||||
|
|
||||||
// Refer to: https://github.com/actions/runner-images?tab=readme-ov-file#available-images
|
|
||||||
export const DEFAULT_RUNNER_LABELS = [
|
export const DEFAULT_RUNNER_LABELS = [
|
||||||
"codespaces-prebuild",
|
|
||||||
"macos-13",
|
|
||||||
"macos-13-large",
|
|
||||||
"macos-13-xlarge",
|
|
||||||
"macos-14",
|
|
||||||
"macos-14-large",
|
|
||||||
"macos-14-xlarge",
|
|
||||||
"macos-15",
|
|
||||||
"macos-15-intel",
|
|
||||||
"macos-15-large",
|
|
||||||
"macos-15-xlarge",
|
|
||||||
"macos-26",
|
|
||||||
"macos-26-large",
|
|
||||||
"macos-26-xlarge",
|
|
||||||
"macos-latest",
|
|
||||||
"macos-latest-large",
|
|
||||||
"macos-latest-xlarge",
|
|
||||||
"self-hosted",
|
|
||||||
"ubuntu-22.04",
|
|
||||||
"ubuntu-22.04-arm",
|
|
||||||
"ubuntu-24.04",
|
|
||||||
"ubuntu-24.04-arm",
|
|
||||||
"ubuntu-latest",
|
"ubuntu-latest",
|
||||||
|
"ubuntu-24.04",
|
||||||
|
"ubuntu-22.04",
|
||||||
|
"ubuntu-20.04",
|
||||||
"ubuntu-slim",
|
"ubuntu-slim",
|
||||||
|
"windows-latest",
|
||||||
"windows-2022",
|
"windows-2022",
|
||||||
"windows-2025",
|
"windows-2019",
|
||||||
"windows-2025-vs2026",
|
"macos-latest",
|
||||||
"windows-latest"
|
"macos-15",
|
||||||
|
"macos-14",
|
||||||
|
"self-hosted"
|
||||||
];
|
];
|
||||||
|
|
||||||
const runsOnValueProvider = {
|
const runsOnValueProvider = {
|
||||||
|
|||||||
+1
-1
@@ -6,5 +6,5 @@
|
|||||||
"languageservice",
|
"languageservice",
|
||||||
"languageserver"
|
"languageserver"
|
||||||
],
|
],
|
||||||
"version": "0.3.54"
|
"version": "0.3.34"
|
||||||
}
|
}
|
||||||
Generated
+1736
-2397
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -9,7 +9,10 @@
|
|||||||
"./languageserver"
|
"./languageserver"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"lerna": "^9.0.0",
|
"lerna": "^8.2.2",
|
||||||
"typescript": "5.8.3"
|
"typescript": "5.8.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"typescript": "$typescript"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/workflow-parser",
|
"name": "@actions/workflow-parser",
|
||||||
"version": "0.3.54",
|
"version": "0.3.34",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
@@ -48,12 +48,12 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.54",
|
"@actions/expressions": "^0.3.34",
|
||||||
"cronstrue": "^2.21.0",
|
"cronstrue": "^2.21.0",
|
||||||
"yaml": "^2.0.0-8"
|
"yaml": "^2.0.0-8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
@@ -69,6 +69,6 @@
|
|||||||
"prettier": "^2.8.3",
|
"prettier": "^2.8.3",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^4.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,24 +137,6 @@
|
|||||||
],
|
],
|
||||||
"string": {}
|
"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": {
|
"runs": {
|
||||||
"one-of": [
|
"one-of": [
|
||||||
"container-runs",
|
"container-runs",
|
||||||
@@ -260,7 +242,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)"
|
"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": {
|
"pre-if": {
|
||||||
"type": "runs-if",
|
"type": "non-empty-string",
|
||||||
"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)"
|
"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": {
|
"post-entrypoint": {
|
||||||
@@ -268,7 +250,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)"
|
"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": {
|
"post-if": {
|
||||||
"type": "runs-if",
|
"type": "non-empty-string",
|
||||||
"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)"
|
"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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,7 +275,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)"
|
"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": {
|
"pre-if": {
|
||||||
"type": "runs-if",
|
"type": "non-empty-string",
|
||||||
"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)"
|
"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": {
|
"post": {
|
||||||
@@ -301,7 +283,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)"
|
"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": {
|
"post-if": {
|
||||||
"type": "runs-if",
|
"type": "non-empty-string",
|
||||||
"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)"
|
"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,53 +317,4 @@ 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 {isBoolean, isMapping, isScalar, isSequence, isString} from "../templates/tokens/type-guards.js";
|
||||||
import {ErrorPolicy} from "../model/convert.js";
|
import {ErrorPolicy} from "../model/convert.js";
|
||||||
import {Step} from "../model/workflow-template.js";
|
import {Step} from "../model/workflow-template.js";
|
||||||
import {convertToIfCondition, validateRunsIfCondition} from "../model/converter/if-condition.js";
|
import {convertToIfCondition} from "../model/converter/if-condition.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a parsed and converted action.yml file
|
* Represents a parsed and converted action.yml file
|
||||||
@@ -310,7 +310,7 @@ function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns
|
|||||||
|
|
||||||
case "pre-if":
|
case "pre-if":
|
||||||
if (isString(item.value)) {
|
if (isString(item.value)) {
|
||||||
preIf = validateRunsIfCondition(context, item.value, item.value.value);
|
preIf = item.value.value;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns
|
|||||||
|
|
||||||
case "post-if":
|
case "post-if":
|
||||||
if (isString(item.value)) {
|
if (isString(item.value)) {
|
||||||
postIf = validateRunsIfCondition(context, item.value, item.value.value);
|
postIf = item.value.value;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -201,355 +201,4 @@ jobs:
|
|||||||
throw new Error("expected if to be a string (will be converted to expression later)");
|
throw new Error("expected if to be a string (will be converted to expression later)");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Block scalar chomp style preservation", () => {
|
|
||||||
it("preserves clip chomping (|) for literal block scalar", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
TEST: |
|
|
||||||
\${{ github.event_name }}
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const env = build.get(1).value.assertMapping("env");
|
|
||||||
const testToken = env.get(0).value;
|
|
||||||
|
|
||||||
if (!isBasicExpression(testToken)) {
|
|
||||||
throw new Error("expected TEST to be a basic expression");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(testToken.blockScalarHeader).toBe("|");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves strip chomping (|-) for literal block scalar", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
TEST: |-
|
|
||||||
\${{ github.event_name }}
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const env = build.get(1).value.assertMapping("env");
|
|
||||||
const testToken = env.get(0).value;
|
|
||||||
|
|
||||||
if (!isBasicExpression(testToken)) {
|
|
||||||
throw new Error("expected TEST to be a basic expression");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(testToken.blockScalarHeader).toBe("|-");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves keep chomping (|+) for literal block scalar", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
TEST: |+
|
|
||||||
\${{ github.event_name }}
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const env = build.get(1).value.assertMapping("env");
|
|
||||||
const testToken = env.get(0).value;
|
|
||||||
|
|
||||||
if (!isBasicExpression(testToken)) {
|
|
||||||
throw new Error("expected TEST to be a basic expression");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(testToken.blockScalarHeader).toBe("|+");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves folded clip (>) chomping", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
TEST: >
|
|
||||||
\${{ github.event_name }}
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const env = build.get(1).value.assertMapping("env");
|
|
||||||
const testToken = env.get(0).value;
|
|
||||||
|
|
||||||
if (!isBasicExpression(testToken)) {
|
|
||||||
throw new Error("expected TEST to be a basic expression");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(testToken.blockScalarHeader).toBe(">");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves folded strip (>-) chomping", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
TEST: >-
|
|
||||||
\${{ github.event_name }}
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const env = build.get(1).value.assertMapping("env");
|
|
||||||
const testToken = env.get(0).value;
|
|
||||||
|
|
||||||
if (!isBasicExpression(testToken)) {
|
|
||||||
throw new Error("expected TEST to be a basic expression");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(testToken.blockScalarHeader).toBe(">-");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves with explicit indent (|2)", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
TEST: |2
|
|
||||||
\${{ github.event_name }}
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const env = build.get(1).value.assertMapping("env");
|
|
||||||
const testToken = env.get(0).value;
|
|
||||||
|
|
||||||
if (!isBasicExpression(testToken)) {
|
|
||||||
throw new Error("expected TEST to be a basic expression");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(testToken.blockScalarHeader).toBe("|2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves with explicit indent and strip (|-2)", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
TEST: |-2
|
|
||||||
\${{ github.event_name }}
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const env = build.get(1).value.assertMapping("env");
|
|
||||||
const testToken = env.get(0).value;
|
|
||||||
|
|
||||||
if (!isBasicExpression(testToken)) {
|
|
||||||
throw new Error("expected TEST to be a basic expression");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(testToken.blockScalarHeader).toBe("|-2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles flow scalars (no chomp info for inline)", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
TEST: \${{ github.event_name }}
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const env = build.get(1).value.assertMapping("env");
|
|
||||||
const testToken = env.get(0).value;
|
|
||||||
|
|
||||||
if (!isBasicExpression(testToken)) {
|
|
||||||
throw new Error("expected TEST to be a basic expression");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(testToken.blockScalarHeader).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves block scalar info for format expressions with multiple sub-expressions", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
TEST: |
|
|
||||||
Hello \${{ github.event_name }} World \${{ github.ref }}
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const env = build.get(1).value.assertMapping("env");
|
|
||||||
const testToken = env.get(0).value;
|
|
||||||
|
|
||||||
if (!isBasicExpression(testToken)) {
|
|
||||||
throw new Error("expected TEST to be a basic expression");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The format expression should preserve the block scalar info
|
|
||||||
expect(testToken.blockScalarHeader).toBe("|");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves block scalar info on StringToken for isExpression fields", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: |
|
|
||||||
github.event_name == 'push'
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const ifToken = build.get(1).value;
|
|
||||||
|
|
||||||
// For isExpression fields without ${{ }}, the token is a StringToken
|
|
||||||
if (!isString(ifToken)) {
|
|
||||||
throw new Error("expected if to be a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(ifToken.blockScalarHeader).toBe("|");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves block scalar info on StringToken for isExpression fields with strip", () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "test.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: |-
|
|
||||||
github.event_name == 'push'
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
|
|
||||||
const workflowRoot = result.value!.assertMapping("root")!;
|
|
||||||
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
|
|
||||||
const build = jobs.get(0).value.assertMapping("job");
|
|
||||||
const ifToken = build.get(1).value;
|
|
||||||
|
|
||||||
if (!isString(ifToken)) {
|
|
||||||
throw new Error("expected if to be a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(ifToken.blockScalarHeader).toBe("|-");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -578,86 +578,4 @@ jobs:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("schedule timezone", () => {
|
|
||||||
it("allows timezone in schedule", async () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "wf.yaml",
|
|
||||||
content: `on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 0 * * *'
|
|
||||||
timezone: America/New_York
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
|
||||||
errorPolicy: ErrorPolicy.TryConversion
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
expect(template.events?.schedule).toHaveLength(1);
|
|
||||||
expect(template.events?.schedule?.[0]).toEqual({
|
|
||||||
cron: "0 0 * * *",
|
|
||||||
timezone: "America/New_York"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports error when cron is missing from schedule entry", async () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "wf.yaml",
|
|
||||||
content: `on:
|
|
||||||
schedule:
|
|
||||||
- timezone: America/New_York
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
|
||||||
errorPolicy: ErrorPolicy.TryConversion
|
|
||||||
});
|
|
||||||
|
|
||||||
// Both schema validation and converter report the missing cron
|
|
||||||
expect(result.context.errors.getErrors().length).toBeGreaterThanOrEqual(1);
|
|
||||||
const errorMessages = result.context.errors
|
|
||||||
.getErrors()
|
|
||||||
.map(e => e.message)
|
|
||||||
.join(", ");
|
|
||||||
expect(errorMessages).toMatch(/Required property is missing: cron|Missing required key 'cron'/);
|
|
||||||
expect(template.events?.schedule).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts schedule without timezone", async () => {
|
|
||||||
const result = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "wf.yaml",
|
|
||||||
content: `on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 0 * * *'
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
|
||||||
errorPolicy: ErrorPolicy.TryConversion
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
|
||||||
expect(template.events?.schedule).toHaveLength(1);
|
|
||||||
expect(template.events?.schedule?.[0]).toEqual({
|
|
||||||
cron: "0 0 * * *"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import {FeatureFlags} from "@actions/expressions/features";
|
|
||||||
import {TemplateContext} from "../templates/template-context.js";
|
import {TemplateContext} from "../templates/template-context.js";
|
||||||
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
|
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
|
||||||
import {FileProvider} from "../workflows/file-provider.js";
|
import {FileProvider} from "../workflows/file-provider.js";
|
||||||
@@ -38,18 +37,12 @@ export type WorkflowTemplateConverterOptions = {
|
|||||||
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
|
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
|
||||||
*/
|
*/
|
||||||
errorPolicy?: ErrorPolicy;
|
errorPolicy?: ErrorPolicy;
|
||||||
|
|
||||||
/**
|
|
||||||
* Feature flags for experimental features.
|
|
||||||
*/
|
|
||||||
featureFlags?: FeatureFlags;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
|
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
|
||||||
maxReusableWorkflowDepth: 4,
|
maxReusableWorkflowDepth: 4,
|
||||||
fetchReusableWorkflowDepth: 0,
|
fetchReusableWorkflowDepth: 0,
|
||||||
errorPolicy: ErrorPolicy.ReturnErrorsOnly,
|
errorPolicy: ErrorPolicy.ReturnErrorsOnly
|
||||||
featureFlags: new FeatureFlags()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function convertWorkflowTemplate(
|
export async function convertWorkflowTemplate(
|
||||||
@@ -61,8 +54,6 @@ export async function convertWorkflowTemplate(
|
|||||||
const result = {} as WorkflowTemplate;
|
const result = {} as WorkflowTemplate;
|
||||||
const opts = getOptionsWithDefaults(options);
|
const opts = getOptionsWithDefaults(options);
|
||||||
|
|
||||||
context.state.featureFlags = opts.featureFlags;
|
|
||||||
|
|
||||||
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
||||||
result.errors = context.errors.getErrors().map(x => ({
|
result.errors = context.errors.getErrors().map(x => ({
|
||||||
Message: x.message
|
Message: x.message
|
||||||
@@ -151,7 +142,6 @@ function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Requ
|
|||||||
options.fetchReusableWorkflowDepth !== undefined
|
options.fetchReusableWorkflowDepth !== undefined
|
||||||
? options.fetchReusableWorkflowDepth
|
? options.fetchReusableWorkflowDepth
|
||||||
: defaultOptions.fetchReusableWorkflowDepth,
|
: defaultOptions.fetchReusableWorkflowDepth,
|
||||||
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy,
|
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy
|
||||||
featureFlags: options.featureFlags ?? defaultOptions.featureFlags
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type {FeatureFlags} from "@actions/expressions/features";
|
|
||||||
import {TemplateContext} from "../../templates/template-context.js";
|
import {TemplateContext} from "../../templates/template-context.js";
|
||||||
import {TemplateToken} from "../../templates/tokens/template-token.js";
|
import {TemplateToken} from "../../templates/tokens/template-token.js";
|
||||||
import {isString} from "../../templates/tokens/type-guards.js";
|
import {isString} from "../../templates/tokens/type-guards.js";
|
||||||
import {ConcurrencyQueue, ConcurrencySetting} from "../workflow-template.js";
|
import {ConcurrencySetting} from "../workflow-template.js";
|
||||||
|
|
||||||
export function convertConcurrency(context: TemplateContext, token: TemplateToken): ConcurrencySetting {
|
export function convertConcurrency(context: TemplateContext, token: TemplateToken): ConcurrencySetting {
|
||||||
const result: ConcurrencySetting = {};
|
const result: ConcurrencySetting = {};
|
||||||
const featureFlags = context.state.featureFlags as FeatureFlags | undefined;
|
|
||||||
|
|
||||||
if (token.isExpression) {
|
if (token.isExpression) {
|
||||||
return result;
|
return result;
|
||||||
@@ -28,11 +26,6 @@ export function convertConcurrency(context: TemplateContext, token: TemplateToke
|
|||||||
case "cancel-in-progress":
|
case "cancel-in-progress":
|
||||||
result.cancelInProgress = property.value.assertBoolean("cancel-in-progress").value;
|
result.cancelInProgress = property.value.assertBoolean("cancel-in-progress").value;
|
||||||
break;
|
break;
|
||||||
case "queue":
|
|
||||||
if (featureFlags?.isEnabled("allowConcurrencyQueue")) {
|
|
||||||
result.queue = property.value.assertString("queue").value as ConcurrencyQueue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
context.error(propertyName, `Invalid property name: ${propertyName.value}`);
|
context.error(propertyName, `Invalid property name: ${propertyName.value}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,87 +70,13 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertToServiceContainer(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;
|
|
||||||
let entrypoint: StringToken | undefined;
|
|
||||||
let command: 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isString(container)) {
|
|
||||||
image = container.assertString("container item");
|
|
||||||
return {image: image};
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapping = container.assertMapping("container item");
|
|
||||||
if (mapping)
|
|
||||||
for (const item of mapping) {
|
|
||||||
const key = item.key.assertString("container item key");
|
|
||||||
const value = item.value;
|
|
||||||
|
|
||||||
switch (key.value) {
|
|
||||||
case "image":
|
|
||||||
image = value.assertString("container image");
|
|
||||||
break;
|
|
||||||
case "credentials":
|
|
||||||
convertToJobCredentials(context, value);
|
|
||||||
break;
|
|
||||||
case "env":
|
|
||||||
env = value.assertMapping("container env");
|
|
||||||
for (const envItem of env) {
|
|
||||||
envItem.key.assertString("container env value");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "ports":
|
|
||||||
ports = value.assertSequence("container ports");
|
|
||||||
for (const port of ports) {
|
|
||||||
port.assertString("container port");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "volumes":
|
|
||||||
volumes = value.assertSequence("container volumes");
|
|
||||||
for (const volume of volumes) {
|
|
||||||
volume.assertString("container volume");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "options":
|
|
||||||
options = value.assertString("container options");
|
|
||||||
break;
|
|
||||||
case "entrypoint":
|
|
||||||
entrypoint = value.assertString("container entrypoint");
|
|
||||||
break;
|
|
||||||
case "command":
|
|
||||||
command = value.assertString("container command");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
context.error(key, `Unexpected container item key: ${key.value}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!image) {
|
|
||||||
context.error(container, "Container image cannot be empty");
|
|
||||||
} else {
|
|
||||||
return {image, env, ports, volumes, options, entrypoint, command};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
||||||
const serviceList: Container[] = [];
|
const serviceList: Container[] = [];
|
||||||
|
|
||||||
const mapping = services.assertMapping("services");
|
const mapping = services.assertMapping("services");
|
||||||
for (const service of mapping) {
|
for (const service of mapping) {
|
||||||
service.key.assertString("service key");
|
service.key.assertString("service key");
|
||||||
const container = convertToServiceContainer(context, service.value);
|
const container = convertToJobContainer(context, service.value);
|
||||||
if (container) {
|
if (container) {
|
||||||
serviceList.push(container);
|
serviceList.push(container);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,34 +149,23 @@ function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig & Vers
|
|||||||
|
|
||||||
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
|
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
|
||||||
const result = [] as ScheduleConfig[];
|
const result = [] as ScheduleConfig[];
|
||||||
|
|
||||||
for (const item of token) {
|
for (const item of token) {
|
||||||
const mappingToken = item.assertMapping(`event schedule`);
|
const mappingToken = item.assertMapping(`event schedule`);
|
||||||
const config: ScheduleConfig = {cron: ""};
|
if (mappingToken.count == 1) {
|
||||||
let valid = true;
|
const schedule = mappingToken.get(0);
|
||||||
|
const scheduleKey = schedule.key.assertString(`schedule key`);
|
||||||
for (const entry of mappingToken) {
|
if (scheduleKey.value == "cron") {
|
||||||
const key = entry.key.assertString(`schedule key`);
|
const cron = schedule.value.assertString(`schedule cron`);
|
||||||
|
// Validate the cron string
|
||||||
if (key.value === "cron") {
|
|
||||||
const cron = entry.value.assertString(`schedule cron`);
|
|
||||||
if (!isValidCron(cron.value)) {
|
if (!isValidCron(cron.value)) {
|
||||||
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
|
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
|
||||||
}
|
}
|
||||||
config.cron = cron.value;
|
result.push({cron: cron.value});
|
||||||
} else if (key.value === "timezone") {
|
|
||||||
const timezone = entry.value.assertString(`schedule timezone`);
|
|
||||||
config.timezone = timezone.value;
|
|
||||||
} else {
|
} else {
|
||||||
context.error(key, `Invalid schedule key`);
|
context.error(scheduleKey, `Invalid schedule key`);
|
||||||
valid = false;
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
context.error(mappingToken, "Invalid format for 'schedule'");
|
||||||
if (valid && config.cron) {
|
|
||||||
result.push(config);
|
|
||||||
} else if (valid && !config.cron) {
|
|
||||||
context.error(mappingToken, "Missing required key 'cron' in schedule entry");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,32 +136,3 @@ function walkTreeToFindStatusFunctionCalls(tree: Expr | undefined): boolean {
|
|||||||
|
|
||||||
return false;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,14 +34,6 @@ export function convertToActionsEnvironmentRef(
|
|||||||
case "url":
|
case "url":
|
||||||
result.url = property.value;
|
result.url = property.value;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "deployment": {
|
|
||||||
const deploymentValue = property.value.assertBoolean("job environment deployment");
|
|
||||||
if (deploymentValue.value === false) {
|
|
||||||
result.skipDeployment = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,18 +18,14 @@ export type WorkflowTemplate = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConcurrencyQueue = "single" | "max";
|
|
||||||
|
|
||||||
export type ConcurrencySetting = {
|
export type ConcurrencySetting = {
|
||||||
group?: StringToken;
|
group?: StringToken;
|
||||||
cancelInProgress?: boolean;
|
cancelInProgress?: boolean;
|
||||||
queue?: ConcurrencyQueue;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActionsEnvironmentReference = {
|
export type ActionsEnvironmentReference = {
|
||||||
name?: TemplateToken;
|
name?: TemplateToken;
|
||||||
url?: TemplateToken;
|
url?: TemplateToken;
|
||||||
skipDeployment?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowJob = Job | ReusableWorkflowJob;
|
export type WorkflowJob = Job | ReusableWorkflowJob;
|
||||||
@@ -78,8 +74,6 @@ export type Container = {
|
|||||||
ports?: SequenceToken;
|
ports?: SequenceToken;
|
||||||
volumes?: SequenceToken;
|
volumes?: SequenceToken;
|
||||||
options?: StringToken;
|
options?: StringToken;
|
||||||
entrypoint?: StringToken;
|
|
||||||
command?: StringToken;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Credential = {
|
export type Credential = {
|
||||||
@@ -202,7 +196,6 @@ export type SecretConfig = {
|
|||||||
|
|
||||||
export type ScheduleConfig = {
|
export type ScheduleConfig = {
|
||||||
cron: string;
|
cron: string;
|
||||||
timezone?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowFilterConfig = {
|
export type WorkflowFilterConfig = {
|
||||||
|
|||||||
@@ -613,9 +613,7 @@ class TemplateReader {
|
|||||||
`format('${format.join("")}'${args.join("")})`,
|
`format('${format.join("")}'${args.join("")})`,
|
||||||
definitionInfo,
|
definitionInfo,
|
||||||
expressionTokens,
|
expressionTokens,
|
||||||
raw,
|
raw
|
||||||
undefined,
|
|
||||||
token.blockScalarHeader
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,8 +695,7 @@ class TemplateReader {
|
|||||||
definitionInfo,
|
definitionInfo,
|
||||||
undefined,
|
undefined,
|
||||||
token.source,
|
token.source,
|
||||||
expressionRange,
|
expressionRange
|
||||||
token.blockScalarHeader
|
|
||||||
),
|
),
|
||||||
error: undefined
|
error: undefined
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,19 +24,7 @@ export class BasicExpressionToken extends ExpressionToken {
|
|||||||
public readonly expressionRange: TokenRange | undefined;
|
public readonly expressionRange: TokenRange | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The block scalar header (e.g., "|", "|-", "|+", ">", ">-", ">+") if parsed from a YAML block scalar.
|
* @param originalExpressions If the basic expression was transformed from individual expressions, these will be the original ones
|
||||||
*/
|
|
||||||
public readonly blockScalarHeader: string | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param file The file ID where this token originated
|
|
||||||
* @param range The range of the entire expression including `${{` and `}}`
|
|
||||||
* @param expression The expression string without `${{` and `}}` markers
|
|
||||||
* @param definitionInfo Schema definition info for this token
|
|
||||||
* @param originalExpressions If transformed from individual expressions (e.g., format()), these are the originals
|
|
||||||
* @param source The original source string from the YAML
|
|
||||||
* @param expressionRange The range of just the expression, excluding `${{` and `}}`
|
|
||||||
* @param blockScalarHeader The block scalar header (e.g., "|", "|-") if parsed from a YAML block scalar
|
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
file: number | undefined,
|
file: number | undefined,
|
||||||
@@ -45,15 +33,13 @@ export class BasicExpressionToken extends ExpressionToken {
|
|||||||
definitionInfo: DefinitionInfo | undefined,
|
definitionInfo: DefinitionInfo | undefined,
|
||||||
originalExpressions: BasicExpressionToken[] | undefined,
|
originalExpressions: BasicExpressionToken[] | undefined,
|
||||||
source: string | undefined,
|
source: string | undefined,
|
||||||
expressionRange?: TokenRange | undefined,
|
expressionRange?: TokenRange | undefined
|
||||||
blockScalarHeader?: string | undefined
|
|
||||||
) {
|
) {
|
||||||
super(TokenType.BasicExpression, file, range, undefined, definitionInfo);
|
super(TokenType.BasicExpression, file, range, undefined, definitionInfo);
|
||||||
this.expr = expression;
|
this.expr = expression;
|
||||||
this.source = source;
|
this.source = source;
|
||||||
this.originalExpressions = originalExpressions;
|
this.originalExpressions = originalExpressions;
|
||||||
this.expressionRange = expressionRange;
|
this.expressionRange = expressionRange;
|
||||||
this.blockScalarHeader = blockScalarHeader;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get expression(): string {
|
public get expression(): string {
|
||||||
@@ -69,8 +55,7 @@ export class BasicExpressionToken extends ExpressionToken {
|
|||||||
this.definitionInfo,
|
this.definitionInfo,
|
||||||
this.originalExpressions,
|
this.originalExpressions,
|
||||||
this.source,
|
this.source,
|
||||||
this.expressionRange,
|
this.expressionRange
|
||||||
this.blockScalarHeader
|
|
||||||
)
|
)
|
||||||
: new BasicExpressionToken(
|
: new BasicExpressionToken(
|
||||||
this.file,
|
this.file,
|
||||||
@@ -79,8 +64,7 @@ export class BasicExpressionToken extends ExpressionToken {
|
|||||||
this.definitionInfo,
|
this.definitionInfo,
|
||||||
this.originalExpressions,
|
this.originalExpressions,
|
||||||
this.source,
|
this.source,
|
||||||
this.expressionRange,
|
this.expressionRange
|
||||||
this.blockScalarHeader
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,26 +6,23 @@ import {TokenType} from "./types.js";
|
|||||||
export class StringToken extends LiteralToken {
|
export class StringToken extends LiteralToken {
|
||||||
public readonly value: string;
|
public readonly value: string;
|
||||||
public readonly source: string | undefined;
|
public readonly source: string | undefined;
|
||||||
public readonly blockScalarHeader: string | undefined;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
file: number | undefined,
|
file: number | undefined,
|
||||||
range: TokenRange | undefined,
|
range: TokenRange | undefined,
|
||||||
value: string,
|
value: string,
|
||||||
definitionInfo: DefinitionInfo | undefined,
|
definitionInfo: DefinitionInfo | undefined,
|
||||||
source?: string,
|
source?: string
|
||||||
blockScalarHeader?: string
|
|
||||||
) {
|
) {
|
||||||
super(TokenType.String, file, range, definitionInfo);
|
super(TokenType.String, file, range, definitionInfo);
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.source = source;
|
this.source = source;
|
||||||
this.blockScalarHeader = blockScalarHeader;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override clone(omitSource?: boolean): TemplateToken {
|
public override clone(omitSource?: boolean): TemplateToken {
|
||||||
return omitSource
|
return omitSource
|
||||||
? new StringToken(undefined, undefined, this.value, this.definitionInfo, this.source, this.blockScalarHeader)
|
? new StringToken(undefined, undefined, this.value, this.definitionInfo, this.source)
|
||||||
: new StringToken(this.file, this.range, this.value, this.definitionInfo, this.source, this.blockScalarHeader);
|
: new StringToken(this.file, this.range, this.value, this.definitionInfo, this.source);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override toString(): string {
|
public override toString(): string {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion */
|
||||||
import {nullTrace} from "../../test-utils/null-trace.js";
|
import {nullTrace} from "../../test-utils/null-trace.js";
|
||||||
import {parseWorkflow} from "../../workflows/workflow-parser.js";
|
import {parseWorkflow} from "../../workflows/workflow-parser.js";
|
||||||
import {MappingToken} from "./mapping-token.js";
|
|
||||||
import {SequenceToken} from "./sequence-token.js";
|
|
||||||
import {StringToken} from "./string-token.js";
|
import {StringToken} from "./string-token.js";
|
||||||
import {TemplateToken} from "./template-token.js";
|
import {TemplateToken} from "./template-token.js";
|
||||||
|
|
||||||
describe("traverse", () => {
|
describe("traverse", () => {
|
||||||
it("returns parent token, key, and ancestors", () => {
|
it("returns parent token and key", () => {
|
||||||
const workflow = parseWorkflow(
|
const workflow = parseWorkflow(
|
||||||
{
|
{
|
||||||
name: "wf.yaml",
|
name: "wf.yaml",
|
||||||
@@ -20,118 +18,19 @@ describe("traverse", () => {
|
|||||||
const traverser = TemplateToken.traverse(root);
|
const traverser = TemplateToken.traverse(root);
|
||||||
|
|
||||||
// Root
|
// Root
|
||||||
const rootResult = traverser.next()!.value!;
|
expect(traverser.next()!.value).toEqual([undefined, root, undefined]);
|
||||||
expect(rootResult[0]).toBeUndefined();
|
|
||||||
expect(rootResult[1]).toBe(root);
|
|
||||||
expect(rootResult[2]).toBeUndefined();
|
|
||||||
expect(rootResult[3]).toEqual([]);
|
|
||||||
|
|
||||||
// On
|
// On
|
||||||
const onResult = traverser.next().value!;
|
const onResult = traverser.next().value!;
|
||||||
expect(onResult[0]).toBe(root);
|
expect(onResult[0]).toBe(root);
|
||||||
expect(getValue(onResult[1])).toEqual("on");
|
expect(getValue(onResult[1])).toEqual("on");
|
||||||
expect(onResult[2]).toBeUndefined();
|
expect(onResult[2]).toBeUndefined();
|
||||||
expect(onResult[3]).toEqual([root]);
|
|
||||||
|
|
||||||
// Push
|
// Push
|
||||||
const pushResult = traverser.next().value!;
|
const pushResult = traverser.next().value!;
|
||||||
expect(pushResult[0]).toBe(root);
|
expect(pushResult[0]).toBe(root);
|
||||||
expect(getValue(pushResult[1])).toEqual("push");
|
expect(getValue(pushResult[1])).toEqual("push");
|
||||||
expect(getValue(pushResult[2])).toEqual("on");
|
expect(getValue(pushResult[2])).toEqual("on");
|
||||||
expect(pushResult[3]).toEqual([root]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns ancestors for nested mappings", () => {
|
|
||||||
const workflow = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "wf.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
const root = workflow.value!;
|
|
||||||
const results = Array.from(TemplateToken.traverse(root));
|
|
||||||
|
|
||||||
// Find the "ubuntu-latest" token
|
|
||||||
const ubuntuResult = results.find(r => getValue(r[1]) === "ubuntu-latest")!;
|
|
||||||
expect(ubuntuResult).toBeDefined();
|
|
||||||
|
|
||||||
// Ancestors should be: root -> jobs mapping -> build mapping
|
|
||||||
const ancestors = ubuntuResult[3];
|
|
||||||
expect(ancestors.length).toBe(3);
|
|
||||||
expect(ancestors[0]).toBe(root);
|
|
||||||
expect(ancestors[1]).toBeInstanceOf(MappingToken); // jobs mapping
|
|
||||||
expect(ancestors[2]).toBeInstanceOf(MappingToken); // build mapping
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns ancestors for sequences", () => {
|
|
||||||
const workflow = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "wf.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hello`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
const root = workflow.value!;
|
|
||||||
const results = Array.from(TemplateToken.traverse(root));
|
|
||||||
|
|
||||||
// Find the "echo hello" token
|
|
||||||
const echoResult = results.find(r => getValue(r[1]) === "echo hello")!;
|
|
||||||
expect(echoResult).toBeDefined();
|
|
||||||
|
|
||||||
// Ancestors should be: root -> jobs mapping -> build mapping -> steps sequence -> step mapping
|
|
||||||
const ancestors = echoResult[3];
|
|
||||||
expect(ancestors.length).toBe(5);
|
|
||||||
expect(ancestors[0]).toBe(root);
|
|
||||||
expect(ancestors[1]).toBeInstanceOf(MappingToken); // jobs mapping
|
|
||||||
expect(ancestors[2]).toBeInstanceOf(MappingToken); // build mapping
|
|
||||||
expect(ancestors[3]).toBeInstanceOf(SequenceToken); // steps sequence
|
|
||||||
expect(ancestors[4]).toBeInstanceOf(MappingToken); // step mapping
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns correct ancestors for matrix values", () => {
|
|
||||||
const workflow = parseWorkflow(
|
|
||||||
{
|
|
||||||
name: "wf.yaml",
|
|
||||||
content: `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node: [a, b]
|
|
||||||
steps:
|
|
||||||
- run: echo hi`
|
|
||||||
},
|
|
||||||
nullTrace
|
|
||||||
);
|
|
||||||
|
|
||||||
const root = workflow.value!;
|
|
||||||
const results = Array.from(TemplateToken.traverse(root));
|
|
||||||
|
|
||||||
// Find the "a" token (first matrix value)
|
|
||||||
const nodeValueResult = results.find(r => {
|
|
||||||
const token = r[1];
|
|
||||||
return token instanceof StringToken && token.value === "a";
|
|
||||||
})!;
|
|
||||||
expect(nodeValueResult).toBeDefined();
|
|
||||||
|
|
||||||
// Ancestors: root -> jobs mapping -> build mapping -> strategy mapping -> matrix mapping -> node sequence
|
|
||||||
const ancestors = nodeValueResult[3];
|
|
||||||
expect(ancestors.length).toBeGreaterThanOrEqual(5);
|
|
||||||
expect(ancestors[0]).toBe(root);
|
|
||||||
// Last ancestor should be the sequence containing [a, b]
|
|
||||||
expect(ancestors[ancestors.length - 1]).toBeInstanceOf(SequenceToken);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -185,23 +185,14 @@ export abstract class TemplateToken {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all tokens (depth first)
|
* Returns all tokens (depth first)
|
||||||
* @param value The object to traverse
|
* @param value The object to travese
|
||||||
* @param omitKeys Whether to omit mapping keys
|
* @param omitKeys Whether to omit mapping keys
|
||||||
* @yields A tuple of [parent, token, keyToken, ancestors] for each token in the tree
|
|
||||||
*/
|
*/
|
||||||
public static *traverse(
|
public static *traverse(
|
||||||
value: TemplateToken,
|
value: TemplateToken,
|
||||||
omitKeys?: boolean
|
omitKeys?: boolean
|
||||||
): Generator<
|
): Generator<[parent: TemplateToken | undefined, token: TemplateToken, keyToken: TemplateToken | undefined], void> {
|
||||||
[
|
yield [undefined, value, undefined];
|
||||||
parent: TemplateToken | undefined,
|
|
||||||
token: TemplateToken,
|
|
||||||
keyToken: TemplateToken | undefined,
|
|
||||||
ancestors: TemplateToken[]
|
|
||||||
],
|
|
||||||
void
|
|
||||||
> {
|
|
||||||
yield [undefined, value, undefined, []];
|
|
||||||
|
|
||||||
switch (value.templateTokenType) {
|
switch (value.templateTokenType) {
|
||||||
case TokenType.Sequence:
|
case TokenType.Sequence:
|
||||||
@@ -211,7 +202,7 @@ export abstract class TemplateToken {
|
|||||||
while (state.parent) {
|
while (state.parent) {
|
||||||
if (state.moveNext(omitKeys ?? false)) {
|
if (state.moveNext(omitKeys ?? false)) {
|
||||||
value = state.current as TemplateToken;
|
value = state.current as TemplateToken;
|
||||||
yield [state.parent?.current, value, state.currentKey, state.getAncestors()];
|
yield [state.parent?.current, value, state.currentKey];
|
||||||
|
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
case TokenType.Sequence:
|
case TokenType.Sequence:
|
||||||
|
|||||||
@@ -66,19 +66,4 @@ export class TraversalState {
|
|||||||
throw new Error(`Unexpected token type '${this._token.templateTokenType}' when traversing state`);
|
throw new Error(`Unexpected token type '${this._token.templateTokenType}' when traversing state`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the ancestor tokens from root to the current token's parent container.
|
|
||||||
*/
|
|
||||||
public getAncestors(): TemplateToken[] {
|
|
||||||
const ancestors: TemplateToken[] = [];
|
|
||||||
let state: TraversalState | undefined = this.parent;
|
|
||||||
while (state) {
|
|
||||||
if (state.current) {
|
|
||||||
ancestors.unshift(state.current);
|
|
||||||
}
|
|
||||||
state = state.parent;
|
|
||||||
}
|
|
||||||
return ancestors;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1602,10 +1602,6 @@
|
|||||||
"type": "permission-level-any",
|
"type": "permission-level-any",
|
||||||
"description": "Repository contents, commits, branches, downloads, releases, and merges."
|
"description": "Repository contents, commits, branches, downloads, releases, and merges."
|
||||||
},
|
},
|
||||||
"copilot-requests": {
|
|
||||||
"type": "permission-level-write-or-no-access",
|
|
||||||
"description": "GitHub Copilot requests."
|
|
||||||
},
|
|
||||||
"deployments": {
|
"deployments": {
|
||||||
"type": "permission-level-any",
|
"type": "permission-level-any",
|
||||||
"description": "Deployments and deployment statuses."
|
"description": "Deployments and deployment statuses."
|
||||||
@@ -1644,15 +1640,11 @@
|
|||||||
},
|
},
|
||||||
"security-events": {
|
"security-events": {
|
||||||
"type": "permission-level-any",
|
"type": "permission-level-any",
|
||||||
"description": "Code scanning alerts."
|
"description": "Code scanning and Dependabot alerts."
|
||||||
},
|
},
|
||||||
"statuses": {
|
"statuses": {
|
||||||
"type": "permission-level-any",
|
"type": "permission-level-any",
|
||||||
"description": "Commit statuses."
|
"description": "Commit statuses."
|
||||||
},
|
|
||||||
"vulnerability-alerts": {
|
|
||||||
"type": "permission-level-read-or-no-access",
|
|
||||||
"description": "Dependabot alerts."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2054,20 +2046,10 @@
|
|||||||
"cancel-in-progress": {
|
"cancel-in-progress": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "To cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true."
|
"description": "To cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true."
|
||||||
},
|
|
||||||
"queue": {
|
|
||||||
"type": "concurrency-queue",
|
|
||||||
"description": "The queuing mode for the concurrency group. When set to `max`, workflows or jobs will wait in a queue for the concurrency group up to the maximum queue length. Default: `single` meaning at most one item can be pending."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"concurrency-queue": {
|
|
||||||
"allowed-values": [
|
|
||||||
"single",
|
|
||||||
"max"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"job-environment": {
|
"job-environment": {
|
||||||
"description": "The environment that the job references. All environment protection rules must pass before a job referencing the environment is sent to a runner.",
|
"description": "The environment that the job references. All environment protection rules must pass before a job referencing the environment is sent to a runner.",
|
||||||
"context": [
|
"context": [
|
||||||
@@ -2093,10 +2075,6 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"type": "string-runner-context-no-secrets",
|
"type": "string-runner-context-no-secrets",
|
||||||
"description": "The environment URL, which maps to `environment_url` in the deployments API."
|
"description": "The environment URL, which maps to `environment_url` in the deployments API."
|
||||||
},
|
|
||||||
"deployment": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Whether to create a deployment record for this environment. Defaults to true."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2194,7 +2172,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step-uses": {
|
"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, 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.",
|
"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.",
|
||||||
"string": {
|
"string": {
|
||||||
"require-non-empty": true
|
"require-non-empty": true
|
||||||
}
|
}
|
||||||
@@ -2371,7 +2349,7 @@
|
|||||||
"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."
|
"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": {
|
"options": {
|
||||||
"type": "string",
|
"type": "non-empty-string",
|
||||||
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
|
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
|
||||||
},
|
},
|
||||||
"env": "container-env",
|
"env": "container-env",
|
||||||
@@ -2413,7 +2391,7 @@
|
|||||||
],
|
],
|
||||||
"one-of": [
|
"one-of": [
|
||||||
"non-empty-string",
|
"non-empty-string",
|
||||||
"service-container-mapping"
|
"container-mapping"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"container-registry-credentials": {
|
"container-registry-credentials": {
|
||||||
@@ -2642,57 +2620,14 @@
|
|||||||
"cron-mapping": {
|
"cron-mapping": {
|
||||||
"mapping": {
|
"mapping": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"cron": {
|
"cron": "cron-pattern"
|
||||||
"type": "cron-pattern",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"timezone": "timezone-string"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cron-pattern": {
|
"cron-pattern": {
|
||||||
"description": "A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes.",
|
|
||||||
"string": {
|
"string": {
|
||||||
"require-non-empty": true
|
"require-non-empty": true
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"timezone-string": {
|
|
||||||
"description": "A string that represents the time zone a scheduled workflow will run relative to in IANA format (e.g. 'America/New_York' or 'Europe/London'). If omitted, the workflow will run relative to midnight UTC.",
|
|
||||||
"string": {
|
|
||||||
"require-non-empty": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"service-container-mapping": {
|
|
||||||
"mapping": {
|
|
||||||
"properties": {
|
|
||||||
"image": {
|
|
||||||
"type": "non-empty-string",
|
|
||||||
"description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name."
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Additional Docker container resource options."
|
|
||||||
},
|
|
||||||
"env": "container-env",
|
|
||||||
"ports": {
|
|
||||||
"type": "sequence-of-non-empty-string",
|
|
||||||
"description": "An array of ports to expose on the container."
|
|
||||||
},
|
|
||||||
"volumes": {
|
|
||||||
"type": "sequence-of-non-empty-string",
|
|
||||||
"description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
|
|
||||||
},
|
|
||||||
"credentials": "container-registry-credentials",
|
|
||||||
"entrypoint": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Override the default ENTRYPOINT in the service container image."
|
|
||||||
},
|
|
||||||
"command": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Override the default CMD in the service container image."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,27 +152,11 @@ export class YamlObjectReader implements ObjectReader {
|
|||||||
return new BooleanToken(fileId, range, value, undefined);
|
return new BooleanToken(fileId, range, value, undefined);
|
||||||
case "string": {
|
case "string": {
|
||||||
let source: string | undefined;
|
let source: string | undefined;
|
||||||
let blockScalarHeader: string | undefined;
|
|
||||||
|
|
||||||
if (token.srcToken && "source" in token.srcToken) {
|
if (token.srcToken && "source" in token.srcToken) {
|
||||||
source = token.srcToken.source;
|
source = token.srcToken.source;
|
||||||
|
|
||||||
// Extract block scalar header (e.g., |-, |+, >-)
|
|
||||||
//
|
|
||||||
// CST node interfaces are supported and documented per yaml library maintainer:
|
|
||||||
// https://eemeli.org/yaml/#parser -> "For a complete description of CST node
|
|
||||||
// interfaces, please consult the cst.ts source."
|
|
||||||
// See also: https://github.com/eemeli/yaml/issues/643
|
|
||||||
if (token.srcToken.type === "block-scalar" && "props" in token.srcToken) {
|
|
||||||
const props = token.srcToken.props as Array<{type: string; source?: string}>;
|
|
||||||
const headerProp = props.find(p => p.type === "block-scalar-header");
|
|
||||||
if (headerProp?.source) {
|
|
||||||
blockScalarHeader = headerProp.source;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new StringToken(fileId, range, value, undefined, source, blockScalarHeader);
|
return new StringToken(fileId, range, value, undefined, source);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unexpected value type '${typeof value}' when reading object`);
|
throw new Error(`Unexpected value type '${typeof value}' when reading object`);
|
||||||
|
|||||||
+1
-28
@@ -25,11 +25,7 @@ jobs:
|
|||||||
concurrency:
|
concurrency:
|
||||||
group: ref
|
group: ref
|
||||||
cancel-in-progress: ${{ github.ref }}
|
cancel-in-progress: ${{ github.ref }}
|
||||||
build5:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
queue: max
|
|
||||||
|
|
||||||
---
|
---
|
||||||
{
|
{
|
||||||
@@ -145,29 +141,6 @@ jobs:
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"runs-on": "macos-latest"
|
"runs-on": "macos-latest"
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "job",
|
|
||||||
"id": "build5",
|
|
||||||
"name": "build5",
|
|
||||||
"if": {
|
|
||||||
"type": 3,
|
|
||||||
"expr": "success()"
|
|
||||||
},
|
|
||||||
"concurrency": {
|
|
||||||
"type": 2,
|
|
||||||
"map": [
|
|
||||||
{
|
|
||||||
"Key": "group",
|
|
||||||
"Value": "deploy"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Key": "queue",
|
|
||||||
"Value": "max"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"runs-on": "ubuntu-latest"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
include-source: false # Drop file/line/col from output
|
|
||||||
skip:
|
|
||||||
- C#
|
|
||||||
---
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
environment:
|
|
||||||
name: production
|
|
||||||
deployment: false
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
build2:
|
|
||||||
environment:
|
|
||||||
name: staging
|
|
||||||
deployment: true
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- run: echo hi
|
|
||||||
---
|
|
||||||
{
|
|
||||||
"jobs": [
|
|
||||||
{
|
|
||||||
"type": "job",
|
|
||||||
"id": "build",
|
|
||||||
"name": "build",
|
|
||||||
"if": {
|
|
||||||
"type": 3,
|
|
||||||
"expr": "success()"
|
|
||||||
},
|
|
||||||
"environment": {
|
|
||||||
"type": 2,
|
|
||||||
"map": [
|
|
||||||
{
|
|
||||||
"Key": "name",
|
|
||||||
"Value": "production"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Key": "deployment",
|
|
||||||
"Value": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"runs-on": "ubuntu-latest",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"id": "__run",
|
|
||||||
"if": {
|
|
||||||
"type": 3,
|
|
||||||
"expr": "success()"
|
|
||||||
},
|
|
||||||
"run": "echo hi"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "job",
|
|
||||||
"id": "build2",
|
|
||||||
"name": "build2",
|
|
||||||
"if": {
|
|
||||||
"type": 3,
|
|
||||||
"expr": "success()"
|
|
||||||
},
|
|
||||||
"environment": {
|
|
||||||
"type": 2,
|
|
||||||
"map": [
|
|
||||||
{
|
|
||||||
"Key": "name",
|
|
||||||
"Value": "staging"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Key": "deployment",
|
|
||||||
"Value": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"runs-on": "ubuntu-latest",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"id": "__run",
|
|
||||||
"if": {
|
|
||||||
"type": 3,
|
|
||||||
"expr": "success()"
|
|
||||||
},
|
|
||||||
"run": "echo hi"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user