Compare commits

...

14 Commits

Author SHA1 Message Date
Francesco Renzi 4efa31459b works 2025-12-09 10:43:21 +00:00
Francesco Renzi f8ea05739d Add more tests 2025-11-28 15:37:32 +00:00
Francesco Renzi 73dd3c33c4 prettier 2025-11-28 14:57:59 +00:00
Francesco Renzi e5800c8843 Setup CodeActions and add quickfix for missing inputs 2025-11-28 14:56:01 +00:00
Francesco Renzi bba2a01c01 docs 2025-11-28 08:59:43 +00:00
Francesco Renzi ec52bd7358 Add instructions on how to run locally and in neovim 2025-11-27 15:37:14 +00:00
eric sciple 03ffd0c44d Add validation for literal text in if conditions (#216)
* Validate literal text in if-condition format expressions

* test escaped left brace
2025-11-25 11:28:18 -06:00
eric sciple 03d68e89c6 Refactor if-condition to use schema-driven validation and AST-based status function detection (#218)
- Read allowed context from schema definition instead of hardcoded constants
- Parse expressions into AST to accurately detect status functions (avoids false positives from string literals)
- Export ensureStatusFunction helper that combines checking and wrapping logic
- Remove step-if.yml from skipped tests (now passes with accurate detection)
- Add tests for if-condition wrapping in hover/completion position mapping
2025-11-25 08:56:34 -06:00
eric sciple bad1fb96af Remove isExpression flag and implement convertToIfCondition to align with Go parser architecture (#217) 2025-11-24 09:12:26 -06:00
eric sciple 7f8bba4305 Merge pull request #214 from actions/release/0.3.20
Release version 0.3.20
2025-11-19 10:34:20 -06:00
GitHub Actions 43feb1a1f4 Release extension version 0.3.20 2025-11-19 16:32:52 +00:00
eric sciple d4aeaa3f3f Merge pull request #213 from indigok/patch-1
Add new artifact-metadata permission to schema
2025-11-19 10:19:40 -06:00
Indigo e4f8f24be3 Closing bracket
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 14:40:37 -08:00
Indigo 168cf44245 Add new artifact-metadata permission to schema 2025-11-13 13:54:34 -08:00
41 changed files with 2333 additions and 120 deletions
+152
View File
@@ -0,0 +1,152 @@
# Using GitHub Actions Language Server in Neovim
## Prerequisites
- Node.js 18+
- Neovim 0.11+ with the new LSP config format
## Setup Options
### Option 1: Install from npm (Recommended)
Once published, you can install globally:
```bash
npm install -g @actions/languageserver
```
Then configure Neovim to use the installed binary:
```lua
-- ~/.config/nvim/lsp/actionsls.lua
return {
cmd = { "actions-languageserver" },
filetypes = { "yaml.ghaction" }, -- GitHub Actions workflow files only
root_markers = { ".git" },
init_options = {
sessionToken = vim.fn.system("gh auth token"):gsub("%s+", ""),
logLevel = "info",
},
}
```
**Note:** This requires the package to be published to npm first.
### Option 2: Local Development Build
For development or if the npm package isn't published yet:
### 1. Clone and build
```bash
git clone https://github.com/actions/languageservices.git
cd languageservices
npm install
npm run build --workspaces --if-present
```
### 2. Bundle the server
The server needs to be bundled into a single file to avoid ESM module resolution issues:
```bash
cd languageserver
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node18 \
--format=cjs \
--outfile=dist/server-bundled.cjs \
--external:vscode \
--loader:.json=json
```
This creates `dist/server-bundled.cjs` (~5.6MB) that contains the entire server.
### 3. Configure Neovim
Create `~/.config/nvim/lsp/actionsls.lua`:
```lua
return {
cmd = {
"/absolute/path/to/languageservices/languageserver/bin/actions-languageserver",
},
filetypes = { "yaml.ghaction" }, -- GitHub Actions workflow files only
root_markers = { ".git" },
init_options = {
sessionToken = vim.fn.system("gh auth token"):gsub("%s+", ""),
logLevel = "info",
},
}
```
**Important:** Replace `/absolute/path/to/languageservices` with your actual clone path.
## Filetype Detection for GitHub Actions Workflows
To ensure the LSP only runs on GitHub Actions workflow files (not all YAML files), set up filetype detection:
**Option A:** In `~/.config/nvim/init.lua`:
```lua
vim.api.nvim_create_autocmd({"BufRead", "BufNewFile"}, {
pattern = ".github/workflows/*.{yml,yaml}",
callback = function()
vim.bo.filetype = "yaml.ghaction"
end,
})
```
**Option B:** Create `~/.config/nvim/ftdetect/ghaction.vim`:
```vim
au BufRead,BufNewFile .github/workflows/*.yml,*.yaml setfiletype yaml.ghaction
```
This sets the filetype to `yaml.ghaction` for files in `.github/workflows/`, matching the `filetypes` setting in your LSP config.
### 4. Enable the LSP in your init.lua
Add to your Neovim configuration:
```lua
vim.lsp.enable('actionsls')
```
### 5. Restart Neovim
Open any `.github/workflows/*.yml` file. The filetype detection will set it to `yaml.ghaction`, and the language server will attach automatically.
## Files Created
- `languageserver/dist/server-bundled.cjs` - Bundled server (~5.6MB)
- `languageserver/bin/actions-languageserver` - Shell wrapper script
The `dist/` directory is gitignored; you'll need to rebuild after pulling updates.
## Troubleshooting
Check if the server is running:
```vim
:lua =vim.lsp.get_clients()
```
View LSP logs:
```bash
tail -f ~/.local/state/nvim/lsp.log
```
Manually start the server to test:
```vim
:lua vim.lsp.start({name='actionsls', cmd={'/path/to/bin/actions-languageserver'}, root_dir=vim.fn.getcwd(), init_options={sessionToken='', logLevel='info'}})
```
## Notes
- The main code change is in `languageserver/src/index.ts` to use dynamic imports, avoiding loading browser modules in Node.js
- The bundling step is necessary because TypeScript outputs ESM with bare imports that Node.js can't resolve
- Only workflow files in git repositories will activate the LSP (due to `root_markers = { ".git" }`)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.19",
"version": "0.3.20",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import "../dist/cli.bundle.cjs";
+8 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.19",
"version": "0.3.20",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -32,6 +32,7 @@
},
"scripts": {
"build": "tsc --build tsconfig.build.json",
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
@@ -42,9 +43,12 @@
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"watch": "tsc --build tsconfig.build.json --watch"
},
"bin": {
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.19",
"@actions/workflow-parser": "^0.3.19",
"@actions/languageservice": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -61,6 +65,7 @@
"@types/jest": "^29.0.3",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"esbuild": "^0.27.1",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node18 \
--format=cjs \
--outfile=dist/server-bundled.cjs \
--external:vscode \
--loader:.json=json
+17 -1
View File
@@ -1,8 +1,11 @@
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
import {documentLinks, getCodeActions, hover, validate, ValidationConfig} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem,
Connection,
DocumentLink,
@@ -72,6 +75,9 @@ export function initConnection(connection: Connection) {
hoverProvider: true,
documentLinkProvider: {
resolveProvider: false
},
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
}
}
};
@@ -158,6 +164,16 @@ export function initConnection(connection: Connection) {
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
});
connection.onCodeAction(async (params: CodeActionParams): Promise<CodeAction[]> => {
return timeOperation("codeAction", async () => {
return getCodeActions({
uri: params.textDocument.uri,
diagnostics: params.context.diagnostics,
only: params.context.only
});
});
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
+3 -3
View File
@@ -1,12 +1,12 @@
import {Connection} from "vscode-languageserver";
import { Connection } from "vscode-languageserver";
import {
BrowserMessageReader,
BrowserMessageWriter,
createConnection as createBrowserConnection
} from "vscode-languageserver/browser";
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
import { createConnection as createNodeConnection } from "vscode-languageserver/node";
import {initConnection} from "./connection";
import { initConnection } from "./connection";
/** Helper function determining whether we are executing with node runtime */
function isNode(): boolean {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.19",
"version": "0.3.20",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -44,8 +44,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.19",
"@actions/workflow-parser": "^0.3.19",
"@actions/expressions": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
+54
View File
@@ -0,0 +1,54 @@
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
import {CodeActionContext, CodeActionProvider} from "./types";
import {quickfixProviders} from "./quickfix";
// Aggregate all providers by kind
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
[CodeActionKind.QuickFix, quickfixProviders]
// [CodeActionKind. Refactor, refactorProviders],
// [CodeActionKind.Source, sourceProviders],
// etc
]);
export interface CodeActionConfig {
// TODO: actionsMetadataProvider, fileProvider, etc.
}
export interface CodeActionParams {
uri: string;
diagnostics: Diagnostic[];
only?: string[];
}
export function getCodeActions(params: CodeActionParams, config?: CodeActionConfig): CodeAction[] {
const actions: CodeAction[] = [];
const context: CodeActionContext = {
uri: params.uri
};
// 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";
@@ -0,0 +1,67 @@
import { CodeAction, TextEdit } from "vscode-languageserver-types";
import { CodeActionContext, CodeActionProvider } from "../types";
import { DiagnosticCode, MissingInputsDiagnosticData } from "../../validate-action";
export const addMissingInputsProvider: CodeActionProvider = {
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
createCodeAction(context, diagnostic): CodeAction | undefined {
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
if (!data) {
return undefined;
}
const edits = createInputEdits(data);
if (!edits) {
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,
},
},
};
},
};
function createInputEdits(data: MissingInputsDiagnosticData): TextEdit[] | undefined {
const edits: TextEdit[] = [];
if (data.hasWithKey && data.withIndent !== undefined) {
// `with:` exists - use its indentation + 2 for inputs
const inputIndent = " ".repeat(data.withIndent + 2);
const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
return `${inputIndent}${input.name}: ${value}`;
});
edits.push({
range: { start: data.insertPosition, end: data.insertPosition },
newText: inputLines.map(line => line + "\n").join(""),
});
} else {
// No `with:` key - `with:` at step indentation, inputs at step indentation + 2
const withIndent = " ".repeat(data.stepIndent);
const inputIndent = " ".repeat(data.stepIndent + 2);
const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
return `${inputIndent}${input.name}: ${value}`;
});
const newText = [`${withIndent}with:\n`, ...inputLines.map(line => `${line}\n`)].join("");
edits.push({
range: { start: data.insertPosition, end: data.insertPosition },
newText,
});
}
return edits;
}
@@ -0,0 +1,4 @@
import {CodeActionProvider} from "../types";
import {addMissingInputsProvider} from "./add-missing-inputs";
export const quickfixProviders: CodeActionProvider[] = [addMissingInputsProvider];
@@ -0,0 +1,90 @@
import * as path from "path";
import {fileURLToPath} from "url";
import {loadTestCases, runTestCase} from "./runner";
import {ValidationConfig} from "../../validate";
import {ActionMetadata, ActionReference} from "../../action";
import {clearCache} from "../../utils/workflow-cache";
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Mock action metadata provider for tests
const validationConfig: ValidationConfig = {
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
const metadata: Record<string, ActionMetadata> = {
"actions/cache@v1": {
name: "Cache",
description: "Cache dependencies",
inputs: {
path: {
description: "A list of files to cache",
required: true
},
key: {
description: "Cache key",
required: true
},
"restore-keys": {
description: "Restore keys",
required: false
}
}
},
"actions/setup-node@v3": {
name: "Setup Node",
description: "Setup Node. js",
inputs: {
"node-version": {
description: "Node version",
required: true,
default: "16"
}
}
}
};
return Promise.resolve(metadata[key]);
}
}
};
// Point to the source testdata directory
const testdataDir = path.join(__dirname, "testdata");
beforeEach(() => {
clearCache();
});
describe("code action golden tests", () => {
const testCases = loadTestCases(testdataDir);
if (testCases.length === 0) {
it.todo("no test cases found - add . yml files to testdata/");
return;
}
for (const testCase of testCases) {
it(testCase.name, async () => {
const result = await runTestCase(testCase, validationConfig);
if (!result.passed) {
let errorMessage = result.error || "Test failed";
if (result.expected !== undefined && result.actual !== undefined) {
errorMessage += "\n\n";
errorMessage += "=== EXPECTED (golden file) ===\n";
errorMessage += result.expected;
errorMessage += "\n\n";
errorMessage += "=== ACTUAL ===\n";
errorMessage += result.actual;
}
throw new Error(errorMessage);
}
});
}
});
@@ -0,0 +1,227 @@
import * as fs from "fs";
import * as path from "path";
import { TextEdit } from "vscode-languageserver-types";
import { TextDocument } from "vscode-languageserver-textdocument";
import { validate, ValidationConfig } from "../../validate";
import { getCodeActions, CodeActionParams } from "../index";
// 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));
console.log(found);
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,
diagnostics: [diagnostic]
};
const actions = getCodeActions(params);
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
};
}
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,7 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -0,0 +1,10 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: ${{ runner.os }}-
path: ""
key: ""
@@ -0,0 +1,8 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
restore-keys: ${{ runner.os }}-
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,6 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
+21
View File
@@ -0,0 +1,21 @@
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
export interface CodeActionContext {
uri: string;
// TODO: add things like workflow template, parsed content, etc.
}
/**
* 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;
}
@@ -69,6 +69,59 @@ jobs:
}
});
});
it("job-level if condition without status function (gets wrapped)", () => {
expect(
testMapToExpressionPos(`on: push
jobs:
build:
if: git|hub.event_name == 'push'
runs-on: ubuntu-latest`)
).toEqual<ExpressionPos>({
expression: "success() && (github.event_name == 'push')",
position: {line: 0, column: 17}, // "success() && (".length + 3 = 17
documentRange: {
start: {line: 3, character: 8},
end: {line: 3, character: 35} // End of the original condition in the document
}
});
});
it("job-level if condition with status function (not wrapped)", () => {
expect(
testMapToExpressionPos(`on: push
jobs:
build:
if: alw|ays()
runs-on: ubuntu-latest`)
).toEqual<ExpressionPos>({
expression: "always()",
position: {line: 0, column: 3},
documentRange: {
start: {line: 3, character: 8},
end: {line: 3, character: 16}
}
});
});
it("step-level if condition without status function (gets wrapped)", () => {
expect(
testMapToExpressionPos(`on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: steps.test.outc|ome == 'success'
run: echo hello`)
).toEqual<ExpressionPos>({
expression: "success() && (steps.test.outcome == 'success')",
position: {line: 0, column: 29}, // Actual position in the wrapped expression
documentRange: {
start: {line: 5, character: 12},
end: {line: 5, character: 43} // End of the original condition in the document
}
});
});
});
function testMapToExpressionPos(input: string) {
@@ -1,6 +1,7 @@
import {Pos} from "@actions/expressions/lexer";
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {isBasicExpression} from "@actions/workflow-parser/templates/tokens/type-guards";
import {isBasicExpression, isString} from "@actions/workflow-parser/templates/tokens/type-guards";
import {Position, Range as LSPRange} from "vscode-languageserver-textdocument";
import {mapRange} from "../utils/range";
import {posWithinRange} from "./pos-range";
@@ -16,12 +17,52 @@ export type ExpressionPos = {
documentRange: LSPRange;
};
/**
* Maps a document position to an expression position for hover/completion features.
*
* This handles both explicit expressions (with ${{ }}) and implicit expressions (like if conditions).
* For if conditions without ${{ }}, this applies the same conversion as the parser's convertToIfCondition,
* wrapping them in `success() && (...)` when no status function is present.
*
* @param token The template token at the position
* @param position The position in the document
* @returns Expression and adjusted position, or undefined if not an expression
*/
export function mapToExpressionPos(token: TemplateToken, position: Position): ExpressionPos | undefined {
const pos: Pos = {
line: position.line + 1,
column: position.character + 1
};
// Handle if conditions that are string tokens (job-if, step-if, snapshot-if)
const definitionKey = token.definition?.key;
if (
isString(token) &&
token.range &&
(definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if")
) {
const condition = token.value.trim();
if (condition) {
// Ensure the condition has a status function, wrapping if needed
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
const exprRange = mapRange(token.range);
// Calculate offset: find where the original condition appears in the final expression
// If wrapped, it will be after "success() && (", otherwise it's at position 0
const offset = finalCondition.indexOf(condition);
return {
expression: finalCondition,
position: {
line: pos.line - exprRange.start.line - 1,
column: pos.column - exprRange.start.character - 1 + offset
},
documentRange: exprRange
};
}
}
if (!isBasicExpression(token)) {
return undefined;
}
@@ -155,8 +155,8 @@ jobs:
contents:
"Causes the step to always execute, and returns `true`, even when canceled. The `always` expression is best used at the step level or on tasks that you expect to run even when a job is canceled. For example, you can use `always` to send logs even when a job is canceled.",
range: {
start: {line: 3, character: 11},
end: {line: 3, character: 17}
start: {line: 3, character: 8},
end: {line: 3, character: 14}
}
});
});
+1
View File
@@ -5,3 +5,4 @@ export {hover} from "./hover";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log";
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
export {getCodeActions} from "./code-actions";
@@ -1,12 +1,11 @@
import {isString} from "@actions/workflow-parser";
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
import {StringDefinition} from "@actions/workflow-parser/templates/schema/string-definition";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
export function isPotentiallyExpression(token: TemplateToken): boolean {
const isAlwaysExpression =
token.definition?.definitionType === DefinitionType.String && (token.definition as StringDefinition).isExpression;
const containsExpression = isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0;
return isAlwaysExpression || containsExpression;
// If conditions are always expressions (job-if, step-if, snapshot-if)
const definitionKey = token.definition?.key;
const isIfCondition = definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if";
return containsExpression || isIfCondition;
}
+67 -12
View File
@@ -1,12 +1,30 @@
import {isMapping} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {parseActionReference} from "./action";
import {mapRange} from "./utils/range";
import {ValidationConfig} from "./validate";
import { isMapping } from "@actions/workflow-parser";
import { isActionStep } from "@actions/workflow-parser/model/type-guards";
import { Step } from "@actions/workflow-parser/model/workflow-template";
import { ScalarToken } from "@actions/workflow-parser/templates/tokens/scalar-token";
import { TemplateToken } from "@actions/workflow-parser/templates/tokens/template-token";
import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver-types";
import { ActionReference, parseActionReference } from "./action";
import { mapRange } from "./utils/range";
import { ValidationConfig } from "./validate";
export const DiagnosticCode = {
MissingRequiredInputs: "missing-required-inputs"
} as const;
export interface MissingInputsDiagnosticData {
action: ActionReference;
missingInputs: Array<{
name: string;
default?: string;
}>;
hasWithKey: boolean;
// Indentation of the `with:` key if present, or the step's base indentation
withIndent?: number;
stepIndent: number;
// Position where new content should be inserted
insertPosition: { line: number; character: number };
}
export async function validateAction(
diagnostics: Diagnostic[],
@@ -35,7 +53,7 @@ export async function validateAction(
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
for (const { key, value } of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
@@ -45,7 +63,7 @@ export async function validateAction(
const stepInputs = new Map<string, ScalarToken>();
if (withToken && isMapping(withToken)) {
for (const {key} of withToken) {
for (const { key } of withToken) {
stepInputs.set(key.toString(), key);
}
}
@@ -83,10 +101,47 @@ export async function validateAction(
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
const stepIndent = stepToken.range ? stepToken.range.start.column - 1 : 0; // 0-indexed
const withIndent = withKey?.range ? withKey.range.start.column - 1 : undefined;
// Calculate insert position
// For withToken, we need to handle empty mappings specially - insert after the with: line
let insertPosition: { line: number; character: number };
if (withToken?.range) {
// Check if with: has any children by comparing start and end lines
const hasChildren = stepInputs.size > 0;
if (hasChildren) {
// Insert after the last child
insertPosition = { line: withToken.range.end.line - 1, character: 0 };
} else {
// Empty with: block - insert on the next line after with:
insertPosition = { line: withKey!.range!.end.line, character: 0 };
}
} else if (stepToken.range) {
insertPosition = { line: stepToken.range.end.line - 1, character: 0 };
} else {
insertPosition = { line: 0, character: 0 };
}
const diagnosticData: MissingInputsDiagnosticData = {
action,
missingInputs: missingRequiredInputs.map(([name, input]) => ({
name,
default: input.default
})),
hasWithKey: withKey !== undefined,
withIndent,
stepIndent,
insertPosition
};
diagnostics.push({
severity: DiagnosticSeverity.Error,
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
});
}
}
+74 -3
View File
@@ -249,7 +249,28 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
hasWithKey: true,
insertPosition: {
character: 0,
line: 9
},
missingInputs: [
{
default: undefined,
name: "path"
}
],
stepIndent: 6,
withIndent: 6
}
}
]);
});
@@ -294,7 +315,32 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
hasWithKey: true,
insertPosition: {
character: 0,
line: 9
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
],
stepIndent: 6,
withIndent: 6
}
}
]);
});
@@ -323,7 +369,32 @@ jobs:
line: 6
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
hasWithKey: false,
insertPosition: {
character: 0,
line: 7
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
],
stepIndent: 6,
withIndent: undefined
}
}
]);
});
@@ -0,0 +1,214 @@
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {registerLogger} from "./log";
import {createDocument} from "./test-utils/document";
import {TestLogger} from "./test-utils/logger";
import {clearCache} from "./utils/workflow-cache";
import {validate} from "./validate";
registerLogger(new TestLogger());
beforeEach(() => {
clearCache();
});
describe("expression literal text in conditions", () => {
describe("job-if", () => {
it("errors when literal text mixed with embedded expression", async () => {
const input = `
on: push
jobs:
build:
if: push == \${{ github.event_name }}
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
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 ${{ }}?",
code: "expression-literal-text-in-condition",
severity: DiagnosticSeverity.Error
})
);
});
it("allows format with only replacement tokens", async () => {
const input = `
on: push
jobs:
build:
if: \${{ format('{0}', github.event_name) }}
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
it("allows format with only replacement tokens and whitespace", async () => {
const input = `
on: push
jobs:
build:
if: \${{ format('{0}{1}', github.event_name, 'test') }}
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
// Only replacement tokens, no literal text
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
it("errors with literal text and replacement tokens mixed", async () => {
const input = `
on: push
jobs:
build:
if: \${{ format('event is {0}', github.event_name) }}
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
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 ${{ }}?",
code: "expression-literal-text-in-condition",
severity: DiagnosticSeverity.Error
})
);
});
it("errors with escaped left brace followed by replacement token", async () => {
const input = `
on: push
jobs:
build:
if: \${{ format('{{{0}', github.event_name) }}
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
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 ${{ }}?",
code: "expression-literal-text-in-condition",
severity: DiagnosticSeverity.Error
})
);
});
});
describe("step-if", () => {
it("errors when literal text mixed with embedded expression", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: success == \${{ job.status }}
run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
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 ${{ }}?",
code: "expression-literal-text-in-condition",
severity: DiagnosticSeverity.Error
})
);
});
it("allows valid expressions", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: \${{ success() }}
run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
});
describe("snapshot-if", () => {
it("errors when literal text mixed with embedded expression", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest]
steps:
- run: echo hi
snapshot:
image-name: my-image
if: ubuntu == \${{ matrix.os }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
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 ${{ }}?",
code: "expression-literal-text-in-condition",
severity: DiagnosticSeverity.Error
})
);
});
});
describe("non-if fields", () => {
it("does not error for format in run", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('Event is {0}', github.event_name) }}
`;
const result = await validate(createDocument("wf.yaml", input));
// Format with literal text is OK outside of if conditions
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
});
});
@@ -1505,4 +1505,174 @@ jobs:
expect(result).toEqual([]);
});
});
describe("if condition context restrictions", () => {
describe("job-level if", () => {
it("allows github context", async () => {
const input = `
on: push
jobs:
build:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows needs context", async () => {
const input = `
on: push
jobs:
a:
runs-on: ubuntu-latest
steps:
- run: echo hello
b:
needs: a
if: needs.a.result == 'success'
runs-on: ubuntu-latest
steps:
- run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows inputs context", async () => {
const input = `
on:
workflow_dispatch:
inputs:
environment:
type: string
jobs:
build:
if: inputs.environment == 'prod'
runs-on: ubuntu-latest
steps:
- run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
// Note: vars and matrix contexts are validated at runtime based on their existence
// vars context only exists if organization/repository variables are defined
// matrix context only exists if a strategy.matrix is defined
});
describe("step-level if", () => {
it("allows steps context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: setup
run: echo hello
- if: steps.setup.outcome == 'success'
run: echo world`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows job context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: job.status == 'success'
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows runner context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: runner.os == 'Linux'
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows env context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
env:
MY_VAR: value
steps:
- if: env.MY_VAR == 'value'
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows matrix context in matrix job", async () => {
const input = `
on: push
jobs:
build:
strategy:
matrix:
os: [ubuntu, windows]
runs-on: ubuntu-latest
steps:
- if: matrix.os == 'ubuntu'
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows hashFiles function", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: hashFiles('**/*.txt') != ''
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows all contexts together", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
env:
JOB_VAR: job-value
steps:
- id: first
run: echo hello
- if: github.event_name == 'push' && steps.first.outcome == 'success' && job.status == 'success' && runner.os == 'Linux' && env.JOB_VAR == 'job-value'
run: echo world`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
});
});
});
+123 -6
View File
@@ -1,7 +1,8 @@
import {Lexer, Parser} from "@actions/expressions";
import {Expr} from "@actions/expressions/ast";
import {Lexer, Parser, data} from "@actions/expressions";
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
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 {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
@@ -104,10 +105,44 @@ async function additionalValidations(
token,
validationToken.definitionInfo?.allowedContext || [],
config?.contextProviderConfig,
getProviderContext(documentUri, template, root, token.range)
getProviderContext(documentUri, template, root, token.range),
key?.definition?.key
);
}
// If this is a job-if, step-if, or snapshot-if field (which are strings that should be treated as expressions), validate it
const definitionKey = token.definition?.key;
if (
isString(token) &&
token.range &&
(definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if")
) {
// Convert the string to an expression token for validation
const condition = token.value.trim();
if (condition) {
// Ensure the condition has a status function, wrapping if needed
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
// Create a BasicExpressionToken for validation
const expressionToken = new BasicExpressionToken(
token.file,
token.range,
finalCondition,
token.definitionInfo,
undefined,
token.source
);
await validateExpression(
diagnostics,
expressionToken,
validationToken.definitionInfo?.allowedContext || [],
config?.contextProviderConfig,
getProviderContext(documentUri, template, root, token.range)
);
}
}
if (token.definition?.key === "regular-step" && token.range) {
const context = getProviderContext(documentUri, template, root, token.range);
await validateAction(diagnostics, token, context.step, config);
@@ -179,17 +214,99 @@ function getProviderContext(
return getWorkflowContext(documentUri, template, path);
}
/**
* Checks if a format function contains literal text in its format string.
* This indicates user confusion about how expressions work.
*
* Example: format('push == {0}', github.event_name)
* The literal text "push == " will always evaluate to truthy.
*
* @param expr The expression to check
* @returns true if the expression is a format() call with literal text
*/
function hasFormatWithLiteralText(expr: Expr): boolean {
// If this is a logical AND expression (from ensureStatusFunction wrapping)
// check the right side for the format call
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
return hasFormatWithLiteralText(expr.args[1]);
}
if (!(expr instanceof FunctionCall)) {
return false;
}
// Check if this is a format function
if (expr.functionName.lexeme.toLowerCase() !== "format") {
return false;
}
// Check if the first argument is a string literal
if (expr.args.length < 1) {
return false;
}
const firstArg = expr.args[0];
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
return false;
}
// Get the format string and trim whitespace
const formatString = firstArg.literal.coerceString();
const trimmed = formatString.trim();
// Check if there's literal text (non-replacement tokens) after trimming
let inToken = false;
for (let i = 0; i < trimmed.length; i++) {
if (!inToken && trimmed[i] === "{") {
inToken = true;
} else if (inToken && trimmed[i] === "}") {
inToken = false;
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
// OK - this is a replacement token like {0}, {1}, etc.
} else {
// Found literal text
return true;
}
}
return false;
}
async function validateExpression(
diagnostics: Diagnostic[],
token: BasicExpressionToken,
allowedContext: string[],
contextProviderConfig: ContextProviderConfig | undefined,
workflowContext: WorkflowContext
workflowContext: WorkflowContext,
keyDefinitionKey?: string
) {
const {namedContexts, functions} = splitAllowedContext(allowedContext);
// Check for literal text in if condition
const definitionKey = keyDefinitionKey || token.definitionInfo?.definition?.key;
if (definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if") {
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:
"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 ${{ }}?",
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: "expression-literal-text-in-condition"
});
}
} catch {
// Ignore parse errors here
}
}
// Validate the expression
for (const expression of token.originalExpressions || [token]) {
const {namedContexts, functions} = splitAllowedContext(allowedContext);
let expr: Expr | undefined;
try {
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.19"
"version": "0.3.20"
}
+509 -46
View File
@@ -135,7 +135,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.19",
"version": "0.3.20",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -395,21 +395,25 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.19",
"version": "0.3.20",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.19",
"@actions/workflow-parser": "^0.3.19",
"@actions/languageservice": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
"vscode-languageserver-textdocument": "^1.0.7",
"yaml": "^2.1.3"
},
"bin": {
"actions-languageserver": "bin/actions-languageserver"
},
"devDependencies": {
"@types/jest": "^29.0.3",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"esbuild": "^0.27.1",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
@@ -475,6 +479,7 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.5.tgz",
"integrity": "sha512-vvmsN0r7rguA+FySiCsbaTTobSftpIDIpPW81trAmsv9TGxg3YCujAxRYp/Uy8xmDgYCzzgulG62H7KYUFmeIg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^5.0.0",
"@octokit/graphql": "^8.2.2",
@@ -921,11 +926,11 @@
},
"languageservice": {
"name": "@actions/languageservice",
"version": "0.3.19",
"version": "0.3.20",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.19",
"@actions/workflow-parser": "^0.3.19",
"@actions/expressions": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -1313,6 +1318,7 @@
"version": "7.20.2",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.18.6",
@@ -1890,6 +1896,448 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz",
"integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz",
"integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz",
"integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz",
"integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
"integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz",
"integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz",
"integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz",
"integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz",
"integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz",
"integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz",
"integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz",
"integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz",
"integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz",
"integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz",
"integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz",
"integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz",
"integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz",
"integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz",
"integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz",
"integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz",
"integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz",
"integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz",
"integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz",
"integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz",
"integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz",
"integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.3.0.tgz",
@@ -2794,22 +3242,6 @@
"node": ">=10"
}
},
"node_modules/@lerna/create/node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@lerna/create/node_modules/write-file-atomic": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
@@ -3723,6 +4155,7 @@
"integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^4.0.0",
"@octokit/graphql": "^7.1.0",
@@ -4250,6 +4683,7 @@
"integrity": "sha512-sn1OZmBxUsgxMmR8a8U5QM/Wl+tyqlH//jTqCg8daTAmhAk26L2PFhcqPLlYBhYUJMZJK276qLXlHN3a83o2cg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.56.0",
"@typescript-eslint/types": "5.56.0",
@@ -4488,6 +4922,7 @@
"version": "8.8.1",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4911,6 +5346,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001400",
"electron-to-chromium": "^1.4.251",
@@ -5931,27 +6367,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/encoding": {
"version": "0.1.13",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -6060,6 +6475,48 @@
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.1",
"@esbuild/android-arm": "0.27.1",
"@esbuild/android-arm64": "0.27.1",
"@esbuild/android-x64": "0.27.1",
"@esbuild/darwin-arm64": "0.27.1",
"@esbuild/darwin-x64": "0.27.1",
"@esbuild/freebsd-arm64": "0.27.1",
"@esbuild/freebsd-x64": "0.27.1",
"@esbuild/linux-arm": "0.27.1",
"@esbuild/linux-arm64": "0.27.1",
"@esbuild/linux-ia32": "0.27.1",
"@esbuild/linux-loong64": "0.27.1",
"@esbuild/linux-mips64el": "0.27.1",
"@esbuild/linux-ppc64": "0.27.1",
"@esbuild/linux-riscv64": "0.27.1",
"@esbuild/linux-s390x": "0.27.1",
"@esbuild/linux-x64": "0.27.1",
"@esbuild/netbsd-arm64": "0.27.1",
"@esbuild/netbsd-x64": "0.27.1",
"@esbuild/openbsd-arm64": "0.27.1",
"@esbuild/openbsd-x64": "0.27.1",
"@esbuild/openharmony-arm64": "0.27.1",
"@esbuild/sunos-x64": "0.27.1",
"@esbuild/win32-arm64": "0.27.1",
"@esbuild/win32-ia32": "0.27.1",
"@esbuild/win32-x64": "0.27.1"
}
},
"node_modules/escalade": {
"version": "3.1.1",
"dev": true,
@@ -6080,6 +6537,7 @@
"version": "7.32.0",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "7.12.11",
"@eslint/eslintrc": "^0.4.3",
@@ -7950,6 +8408,7 @@
"version": "29.3.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.3.1",
"@jest/types": "^29.3.1",
@@ -9024,6 +9483,7 @@
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9844,6 +10304,7 @@
"version": "2.6.7",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
@@ -10197,6 +10658,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@napi-rs/wasm-runtime": "0.2.4",
"@yarnpkg/lockfile": "^1.1.0",
@@ -10833,6 +11295,7 @@
"version": "2.8.3",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin-prettier.js"
},
@@ -12917,10 +13380,10 @@
},
"workflow-parser": {
"name": "@actions/workflow-parser",
"version": "0.3.19",
"version": "0.3.20",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.19",
"@actions/expressions": "^0.3.20",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.19",
"version": "0.3.20",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -45,7 +45,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.19",
"@actions/expressions": "^0.3.20",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+4 -3
View File
@@ -194,10 +194,11 @@ jobs:
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const ifToken = build.get(1).value;
expect(ifToken.toString()).toEqual("${{ github.event_name == 'push' }}");
// Without isExpression: true, the value is kept as a string until convertToIfCondition processes it
expect(ifToken.toString()).toEqual("github.event_name == 'push'");
if (!isBasicExpression(ifToken)) {
throw new Error("expected if to be a basic expression");
if (!isString(ifToken)) {
throw new Error("expected if to be a string (will be converted to expression later)");
}
});
});
+198 -2
View File
@@ -74,7 +74,7 @@ jobs:
{
id: "build",
if: {
expr: "success()",
expr: "success() && (true)",
type: 3
},
name: "build",
@@ -85,7 +85,7 @@ jobs:
{
id: "deploy",
if: {
expr: "success()",
expr: "success() && (true)",
type: 3
},
name: "deploy",
@@ -382,4 +382,200 @@ jobs:
]
});
});
describe("if condition context validation", () => {
it("validates job-level if with allowed contexts", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
if: github.event_name == 'push' && needs.test.result == 'success'
needs: test
runs-on: ubuntu-latest
test:
runs-on: ubuntu-latest`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Should convert successfully - github and needs are allowed in job-level if
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.jobs).toHaveLength(2);
});
it("validates job-level if rejects disallowed contexts", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
if: steps.test.outcome == 'success'
runs-on: ubuntu-latest
steps:
- id: test
run: echo hello`
},
nullTrace
);
await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Should have error - steps context not allowed in job-level if
const errors = result.context.errors.getErrors();
expect(errors.length).toBeGreaterThan(0);
const errorMessages = errors.map(e => e.message).join(" ");
expect(errorMessages.toLowerCase()).toMatch(/steps|context/);
});
it("validates step-level if allows all contexts", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: first
run: echo hello
- if: steps.first.outcome == 'success' && job.status == 'success'
run: echo world`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Should convert successfully - steps and job contexts allowed in step-level if
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.jobs).toHaveLength(1);
});
it("handles case-insensitive status functions in if conditions", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: Success()
run: echo "uppercase Success"
- if: FAILURE()
run: echo "uppercase FAILURE"
- if: Cancelled() || Always()
run: echo "mixed case"`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Should convert successfully - status functions are case-insensitive
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.jobs).toHaveLength(1);
// Verify the conditions are preserved without wrapping in success() &&
const job = template.jobs[0];
expect(job.type).toBe("job");
if (job.type === "job") {
expect(job.steps[0].if?.expression).toBe("Success()");
expect(job.steps[1].if?.expression).toBe("FAILURE()");
expect(job.steps[2].if?.expression).toBe("Cancelled() || Always()");
}
});
it("handles empty if condition", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
job1:
if: ""
runs-on: ubuntu-latest
steps:
- run: echo hello
job2:
if: ''
runs-on: ubuntu-latest
steps:
- if: ""
run: echo world
- if: ''
run: echo test`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Empty conditions should default to success()
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.jobs).toHaveLength(2);
const job1 = template.jobs[0];
expect(job1.if?.expression).toBe("success()");
const job2 = template.jobs[1];
expect(job2.if?.expression).toBe("success()");
if (job2.type === "job") {
expect(job2.steps[0].if?.expression).toBe("success()");
expect(job2.steps[1].if?.expression).toBe("success()");
}
});
it("handles status functions with property access", async () => {
const result = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: success().outputs.result
run: echo "success with property"
- if: failure().outputs.value
run: echo "failure with property"
- if: always() && steps.test.outcome
run: echo "always with &&"`
},
nullTrace
);
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
// Should not wrap - status functions are present even with property access
expect(result.context.errors.getErrors()).toHaveLength(0);
expect(template.jobs).toHaveLength(1);
const job = template.jobs[0];
expect(job.type).toBe("job");
if (job.type === "job") {
expect(job.steps[0].if?.expression).toBe("success().outputs.result");
expect(job.steps[1].if?.expression).toBe("failure().outputs.value");
expect(job.steps[2].if?.expression).toBe("always() && steps.test.outcome");
}
});
});
});
@@ -0,0 +1,138 @@
import {Lexer, Parser} from "@actions/expressions";
import {Binary, Expr, FunctionCall, Grouping, IndexAccess, Logical, Unary} from "@actions/expressions/ast";
import {DefinitionInfo} from "../../templates/schema/definition-info";
import {splitAllowedContext} from "../../templates/allowed-context";
import {TemplateContext} from "../../templates/template-context";
import {BasicExpressionToken, ExpressionToken, TemplateToken} from "../../templates/tokens";
/**
* Ensures a condition expression contains a status function call.
* If the condition doesn't contain success(), failure(), cancelled(), or always(),
* wraps it in `success() && (condition)`.
*
* Parses the expression to accurately detect status functions, avoiding false positives
* from string literals or property access. If parsing fails (e.g., partially typed expression),
* returns the original condition unchanged to allow validation to report the actual error.
*
* @param condition The condition expression to check
* @param definitionInfo Schema definition containing allowed contexts for parsing
* @returns The condition with status function guaranteed, or original on parse error
*/
export function ensureStatusFunction(condition: string, definitionInfo: DefinitionInfo | undefined): string {
const allowedContext = definitionInfo?.allowedContext || [];
try {
const {namedContexts, functions} = splitAllowedContext(allowedContext);
const lexer = new Lexer(condition);
const result = lexer.lex();
const parser = new Parser(result.tokens, namedContexts, functions);
const tree = parser.parse();
// Check if tree contains status function
if (walkTreeToFindStatusFunctionCalls(tree)) {
return condition; // Already has status function
}
// Wrap it
return `success() && (${condition})`;
} catch {
// Parse error - return original and let validation report the actual error
// This is important for hover/autocomplete on partially-typed expressions
return condition;
}
}
/**
* Converts an if condition token to a BasicExpressionToken.
* Treats the value as a string and parses it as an expression.
* Wraps the condition in success() && (...) if it doesn't already contain a status function.
* This allows both 'if: success()' and 'if: ${{ success() }}' to work correctly.
*
* Reads the allowed context directly from the schema definition attached to the token,
* ensuring consistency with the schema.
*
* @param context The template context for error reporting
* @param token The token containing the if condition
* @returns A BasicExpressionToken with the processed condition, or undefined on error
*/
export function convertToIfCondition(context: TemplateContext, token: TemplateToken): BasicExpressionToken | undefined {
const scalar = token.assertScalar("if condition");
// Get allowed context from the schema definition attached to the token
const allowedContext = token.definitionInfo?.allowedContext || [];
// If it's already an expression, use its value
let condition: string;
let source: string | undefined;
if (scalar instanceof BasicExpressionToken) {
condition = scalar.expression;
source = scalar.source;
} else {
// Otherwise, treat it as a string
const stringToken = scalar.assertString("if condition");
condition = stringToken.value.trim();
source = stringToken.source;
}
let finalCondition: string;
if (!condition) {
// Empty condition defaults to success()
finalCondition = "success()";
} else {
// Ensure the condition has a status function, wrapping if needed
finalCondition = ensureStatusFunction(condition, token.definitionInfo);
}
// Validate the expression before creating the token
try {
ExpressionToken.validateExpression(finalCondition, allowedContext);
} catch (err) {
context.error(token, err as Error);
return undefined;
}
// Create a BasicExpressionToken with the final condition
return new BasicExpressionToken(token.file, token.range, finalCondition, token.definitionInfo, undefined, source);
}
/**
* Walks an expression AST to find status function calls (success, failure, cancelled, always).
* Recursively checks all nodes including function arguments and logical/binary operations.
*/
function walkTreeToFindStatusFunctionCalls(tree: Expr | undefined): boolean {
if (!tree) {
return false;
}
if (tree instanceof FunctionCall) {
const funcName = tree.functionName.lexeme.toLowerCase();
if (funcName === "success" || funcName === "failure" || funcName === "cancelled" || funcName === "always") {
return true;
}
// Check arguments recursively
return tree.args.some(arg => walkTreeToFindStatusFunctionCalls(arg));
}
if (tree instanceof Binary) {
return walkTreeToFindStatusFunctionCalls(tree.left) || walkTreeToFindStatusFunctionCalls(tree.right);
}
if (tree instanceof Unary) {
return walkTreeToFindStatusFunctionCalls(tree.expr);
}
if (tree instanceof Logical) {
return tree.args.some(arg => walkTreeToFindStatusFunctionCalls(arg));
}
if (tree instanceof Grouping) {
return walkTreeToFindStatusFunctionCalls(tree.group);
}
if (tree instanceof IndexAccess) {
return walkTreeToFindStatusFunctionCalls(tree.expr) || walkTreeToFindStatusFunctionCalls(tree.index);
}
return false;
}
+8 -2
View File
@@ -2,6 +2,7 @@ import {TemplateContext} from "../../templates/template-context";
import {BasicExpressionToken, MappingToken, ScalarToken, StringToken, TemplateToken} from "../../templates/tokens";
import {isSequence, isString} from "../../templates/tokens/type-guards";
import {Step, WorkflowJob} from "../workflow-template";
import {convertToIfCondition} from "./if-condition";
import {convertConcurrency} from "./concurrency";
import {convertToJobContainer, convertToJobServices} from "./container";
import {handleTemplateTokenErrors} from "./handle-errors";
@@ -20,6 +21,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
container,
env,
environment,
ifCondition,
name,
outputs,
runsOn,
@@ -59,6 +61,10 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
environment = item.value;
break;
case "if":
ifCondition = convertToIfCondition(context, item.value);
break;
case "name":
name = item.value.assertScalar("job name");
break;
@@ -134,7 +140,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
id: jobKey,
name: jobName(name, jobKey),
needs: needs || [],
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
ref: workflowJobRef,
"input-definitions": undefined,
"input-values": workflowJobInputs,
@@ -151,7 +157,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
id: jobKey,
name: jobName(name, jobKey),
needs,
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
env,
concurrency,
environment,
+7 -3
View File
@@ -2,6 +2,7 @@ import {TemplateContext} from "../../templates/template-context";
import {BasicExpressionToken, MappingToken, ScalarToken, StringToken, TemplateToken} from "../../templates/tokens";
import {isSequence} from "../../templates/tokens/type-guards";
import {isActionStep} from "../type-guards";
import {convertToIfCondition} from "./if-condition";
import {ActionStep, Step} from "../workflow-template";
import {handleTemplateTokenErrors} from "./handle-errors";
import {IdBuilder} from "./id-builder";
@@ -52,7 +53,7 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
let uses: StringToken | undefined;
let continueOnError: boolean | ScalarToken | undefined;
let env: MappingToken | undefined;
const ifCondition = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined);
let ifCondition: BasicExpressionToken | undefined;
for (const item of mapping) {
const key = item.key.assertString("steps item key");
switch (key.value) {
@@ -77,6 +78,9 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
case "env":
env = item.value.assertMapping("step env");
break;
case "if":
ifCondition = convertToIfCondition(context, item.value);
break;
case "continue-on-error":
if (!item.value.isExpression) {
continueOnError = item.value.assertBoolean("steps item continue-on-error").value;
@@ -90,7 +94,7 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
return {
id: id?.value || "",
name,
if: ifCondition,
if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
"continue-on-error": continueOnError,
env,
run
@@ -101,7 +105,7 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
return {
id: id?.value || "",
name,
if: ifCondition,
if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
"continue-on-error": continueOnError,
env,
uses
@@ -8,7 +8,6 @@ import {DefinitionType} from "./schema/definition-type";
import {MappingDefinition} from "./schema/mapping-definition";
import {ScalarDefinition} from "./schema/scalar-definition";
import {SequenceDefinition} from "./schema/sequence-definition";
import {StringDefinition} from "./schema/string-definition";
import {ANY, CLOSE_EXPRESSION, INSERT_DIRECTIVE, OPEN_EXPRESSION} from "./template-constants";
import {TemplateContext} from "./template-context";
import {
@@ -456,14 +455,7 @@ class TemplateReader {
let startExpression: number = raw.indexOf(OPEN_EXPRESSION);
if (startExpression < 0) {
// Doesn't contain "${{"
// Check if value should still be evaluated as an expression
if (definitionInfo.definition instanceof StringDefinition && definitionInfo.definition.isExpression) {
const expression = this.parseIntoExpressionToken(token.range!, raw, allowedContext, token, definitionInfo);
if (expression) {
return expression;
}
}
// Doesn't contain "{{"
return token;
}
+7 -9
View File
@@ -1578,6 +1578,10 @@
"type": "permission-level-any",
"description": "Actions workflows, workflow runs, and artifacts."
},
"artifact-metadata": {
"type": "permission-level-any",
"description": "Storage and deployment records for build artifacts."
},
"attestations": {
"type": "permission-level-any",
"description": "Artifact attestations."
@@ -1838,9 +1842,7 @@
"cancelled(0,0)",
"success(0,MAX)"
],
"string": {
"is-expression": true
}
"string": {}
},
"job-if-result": {
"context": [
@@ -1945,9 +1947,7 @@
"matrix"
],
"description": "Use the if conditional to prevent a snapshot from being taken unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.",
"string": {
"is-expression": true
}
"string": {}
},
"runs-on": {
"description": "Use `runs-on` to define the type of machine to run the job on.\n* The destination machine can be either a GitHub-hosted runner, larger runner, or a self-hosted runner.\n* You can target runners based on the labels assigned to them, or their group membership, or a combination of these.\n* You can provide `runs-on` as a single string or as an array of strings.\n* If you specify an array of strings, your workflow will execute on any runner that matches all of the specified `runs-on` values.\n* If you would like to run your workflow on multiple machines, use `jobs.<job_id>.strategy`.",
@@ -2212,9 +2212,7 @@
"hashFiles(1,255)"
],
"description": "Use the `if` conditional to prevent a step from running unless a condition is met. Any supported context and expression can be used to create a conditional. Expressions in an `if` conditional do not require the bracketed expression syntax. When you use expressions in an `if` conditional, you may omit the expression syntax because GitHub automatically evaluates the `if` conditional as an expression.",
"string": {
"is-expression": true
}
"string": {}
},
"step-if-result": {
"context": [
-3
View File
@@ -50,7 +50,6 @@ errors-step-uses-syntax.yml
errors-unclosed-tokens.yml
errors-yaml-invalid-style.yml
errors-yaml-tags-explicit-unsupported.yml
escape-html-values.yml
float-folded-style.yml
insert.yml
is-partial-rerun.yml
@@ -59,7 +58,6 @@ job-cancel-timeout-minutes.yml
job-concurrency.yml
job-continue-on-error.yml
job-defaults.yml
job-if.yml
job-permissions.yml
job-timeout-minutes.yml
matrix-basic.yml
@@ -85,7 +83,6 @@ reusable-workflow-job-permissions-overrides-default-write.yml
reusable-workflow-job-permissions-overrides-workflow-level.yml
root-env-defaults.yml
round-to-infinity.yml
step-if.yml
scientific-notation-number.yml
skip-reusable-workflows.yml
workflow-defaults.yml