Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4efa31459b | ||
|
|
f8ea05739d | ||
|
|
73dd3c33c4 | ||
|
|
e5800c8843 | ||
|
|
bba2a01c01 | ||
|
|
ec52bd7358 |
+152
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/expressions",
|
"name": "@actions/expressions",
|
||||||
"version": "0.3.21",
|
"version": "0.3.20",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
|
|||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import "../dist/cli.bundle.cjs";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageserver",
|
"name": "@actions/languageserver",
|
||||||
"version": "0.3.21",
|
"version": "0.3.20",
|
||||||
"description": "Language server for GitHub Actions",
|
"description": "Language server for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build tsconfig.build.json",
|
"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",
|
"clean": "rimraf dist",
|
||||||
"format": "prettier --write '**/*.ts'",
|
"format": "prettier --write '**/*.ts'",
|
||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
@@ -42,9 +43,12 @@
|
|||||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"actions-languageserver": "./bin/actions-languageserver"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/languageservice": "^0.3.21",
|
"@actions/languageservice": "^0.3.20",
|
||||||
"@actions/workflow-parser": "^0.3.21",
|
"@actions/workflow-parser": "^0.3.20",
|
||||||
"@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",
|
||||||
@@ -61,6 +65,7 @@
|
|||||||
"@types/jest": "^29.0.3",
|
"@types/jest": "^29.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.56.0",
|
"@typescript-eslint/eslint-plugin": "^5.56.0",
|
||||||
"@typescript-eslint/parser": "^5.56.0",
|
"@typescript-eslint/parser": "^5.56.0",
|
||||||
|
"esbuild": "^0.27.1",
|
||||||
"eslint": "^8.36.0",
|
"eslint": "^8.36.0",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
|
|||||||
Executable
+10
@@ -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
|
||||||
@@ -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 {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,
|
||||||
@@ -72,6 +75,9 @@ export function initConnection(connection: Connection) {
|
|||||||
hoverProvider: true,
|
hoverProvider: true,
|
||||||
documentLinkProvider: {
|
documentLinkProvider: {
|
||||||
resolveProvider: false
|
resolveProvider: false
|
||||||
|
},
|
||||||
|
codeActionProvider: {
|
||||||
|
codeActionKinds: [CodeActionKind.QuickFix]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -158,6 +164,16 @@ export function initConnection(connection: Connection) {
|
|||||||
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
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
|
// 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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageservice",
|
"name": "@actions/languageservice",
|
||||||
"version": "0.3.21",
|
"version": "0.3.20",
|
||||||
"description": "Language service for GitHub Actions",
|
"description": "Language service for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.21",
|
"@actions/expressions": "^0.3.20",
|
||||||
"@actions/workflow-parser": "^0.3.21",
|
"@actions/workflow-parser": "^0.3.20",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
languageservice/src/code-actions/tests/testdata/quickfix/existing-with-key-without-inputs.golden.yml
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/cache@v1
|
||||||
|
with:
|
||||||
|
path: ""
|
||||||
|
key: ""
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/cache@v1
|
||||||
|
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/cache@v1
|
||||||
|
with:
|
||||||
|
restore-keys: ${{ runner.os }}-
|
||||||
|
path: ""
|
||||||
|
key: ""
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/cache@v1
|
||||||
|
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||||
|
restore-keys: ${{ runner.os }}-
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/cache@v1
|
||||||
|
with:
|
||||||
|
path: ""
|
||||||
|
key: ""
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -1268,7 +1268,7 @@ jobs:
|
|||||||
on: push
|
on: push
|
||||||
jobs:
|
jobs:
|
||||||
a:
|
a:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
uses: ./reusable-workflow-with-outputs.yaml
|
||||||
b:
|
b:
|
||||||
needs: [a]
|
needs: [a]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
uses: ./reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
|
|
|
|
||||||
`;
|
`;
|
||||||
@@ -49,7 +49,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
uses: ./reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
username: monalisa
|
username: monalisa
|
||||||
|
|
|
|
||||||
@@ -74,7 +74,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
uses: ./reusable-workflow-with-inputs.yaml
|
||||||
secrets:
|
secrets:
|
||||||
|
|
|
|
||||||
`;
|
`;
|
||||||
@@ -102,7 +102,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
uses: ./reusable-workflow-with-inputs.yaml
|
||||||
secrets: |
|
secrets: |
|
||||||
`;
|
`;
|
||||||
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
|
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
|
||||||
@@ -117,7 +117,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
uses: ./reusable-workflow-with-inputs.yaml
|
||||||
secrets:
|
secrets:
|
||||||
envPAT: "myPAT"
|
envPAT: "myPAT"
|
||||||
|
|
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
on: push
|
on: push
|
||||||
jobs:
|
jobs:
|
||||||
a:
|
a:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
uses: ./reusable-workflow-with-outputs.yaml
|
||||||
|
|
||||||
b:
|
b:
|
||||||
needs: [a]
|
needs: [a]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
uses: ./reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
us|ername:
|
us|ername:
|
||||||
`;
|
`;
|
||||||
@@ -31,7 +31,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs-no-description.yaml
|
uses: ./reusable-workflow-with-inputs-no-description.yaml
|
||||||
with:
|
with:
|
||||||
us|ername:
|
us|ername:
|
||||||
`;
|
`;
|
||||||
@@ -48,7 +48,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
uses: ./reusable-workflow-with-outputs.yaml
|
||||||
echo_outputs:
|
echo_outputs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
|
|||||||
@@ -110,8 +110,11 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
const result = await hover(...getPositionFromCursor(input));
|
const result = await hover(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// Cron description is now shown via diagnostics, not hover
|
expect(result?.contents).toEqual(
|
||||||
expect(result?.contents).toEqual("");
|
"Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00\n\n" +
|
||||||
|
"Actions schedules run at most every 5 minutes. " +
|
||||||
|
"[Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("on a cron mapping key", async () => {
|
it("on a cron mapping key", async () => {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import {data, DescriptionDictionary, Parser} from "@actions/expressions";
|
|||||||
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
|
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
|
||||||
import {Lexer} from "@actions/expressions/lexer";
|
import {Lexer} from "@actions/expressions/lexer";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
|
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
|
||||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||||
|
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 {isBasicExpression} from "@actions/workflow-parser/templates/tokens/type-guards";
|
import {isBasicExpression, isString} from "@actions/workflow-parser/templates/tokens/type-guards";
|
||||||
import {File} from "@actions/workflow-parser/workflows/file";
|
import {File} from "@actions/workflow-parser/workflows/file";
|
||||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||||
@@ -21,7 +23,7 @@ import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-p
|
|||||||
import {HoverVisitor} from "./expression-hover/visitor";
|
import {HoverVisitor} from "./expression-hover/visitor";
|
||||||
import {info} from "./log";
|
import {info} from "./log";
|
||||||
import {isPotentiallyExpression} from "./utils/expression-detection";
|
import {isPotentiallyExpression} from "./utils/expression-detection";
|
||||||
import {findToken} from "./utils/find-token";
|
import {findToken, TokenResult} from "./utils/find-token";
|
||||||
import {mapRange} from "./utils/range";
|
import {mapRange} from "./utils/range";
|
||||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||||
|
|
||||||
@@ -87,6 +89,17 @@ export async function hover(document: TextDocument, position: Position, config?:
|
|||||||
|
|
||||||
info(`Calculating hover for token with definition ${token.definition.key}`);
|
info(`Calculating hover for token with definition ${token.definition.key}`);
|
||||||
|
|
||||||
|
if (tokenResult.parent && isCronMappingValue(tokenResult)) {
|
||||||
|
const tokenValue = (token as StringToken).value;
|
||||||
|
const description = getCronDescription(tokenValue);
|
||||||
|
if (description) {
|
||||||
|
return {
|
||||||
|
contents: description,
|
||||||
|
range: mapRange(token.range)
|
||||||
|
} satisfies Hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
||||||
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
|
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
|
||||||
description = appendContext(description, token.definitionInfo?.allowedContext);
|
description = appendContext(description, token.definitionInfo?.allowedContext);
|
||||||
@@ -143,6 +156,15 @@ async function getDescription(
|
|||||||
return description || defaultDescription;
|
return description || defaultDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCronMappingValue(tokenResult: TokenResult): boolean {
|
||||||
|
return (
|
||||||
|
tokenResult.parent?.definition?.key === "cron-mapping" &&
|
||||||
|
!!tokenResult.token &&
|
||||||
|
isString(tokenResult.token) &&
|
||||||
|
tokenResult.token.value !== "cron"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function expressionHover(
|
function expressionHover(
|
||||||
exprPos: ExpressionPos,
|
exprPos: ExpressionPos,
|
||||||
context: DescriptionDictionary,
|
context: DescriptionDictionary,
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export {hover} from "./hover";
|
|||||||
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log";
|
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log";
|
||||||
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate";
|
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate";
|
||||||
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||||
|
export {getCodeActions} from "./code-actions";
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ export const testFileProvider: FileProvider = {
|
|||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
getFileContent: async ref => {
|
getFileContent: async ref => {
|
||||||
switch (fileIdentifier(ref)) {
|
switch (fileIdentifier(ref)) {
|
||||||
case "monalisa/octocat/.github/workflows/workflow.yaml@main":
|
case "monalisa/octocat/workflow.yaml@main":
|
||||||
return {
|
return {
|
||||||
name: "monalisa/octocat/.github/workflows/workflow.yaml",
|
name: "monalisa/octocat/workflow.yaml",
|
||||||
content: `
|
content: `
|
||||||
on: workflow_call
|
on: workflow_call
|
||||||
jobs:
|
jobs:
|
||||||
@@ -31,9 +31,9 @@ jobs:
|
|||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
case "./.github/workflows/reusable-workflow.yaml":
|
case "./reusable-workflow.yaml":
|
||||||
return {
|
return {
|
||||||
name: ".github/workflows/reusable-workflow.yaml",
|
name: "reusable-workflow.yaml",
|
||||||
content: `
|
content: `
|
||||||
on: workflow_call
|
on: workflow_call
|
||||||
jobs:
|
jobs:
|
||||||
@@ -44,9 +44,9 @@ jobs:
|
|||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
case "./.github/workflows/reusable-workflow-with-inputs.yaml":
|
case "./reusable-workflow-with-inputs.yaml":
|
||||||
return {
|
return {
|
||||||
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
|
name: "reusable-workflow-with-inputs.yaml",
|
||||||
content: `
|
content: `
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
@@ -76,9 +76,9 @@ jobs:
|
|||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
case "./.github/workflows/reusable-workflow-with-inputs-no-description.yaml":
|
case "./reusable-workflow-with-inputs-no-description.yaml":
|
||||||
return {
|
return {
|
||||||
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
|
name: "reusable-workflow-with-inputs.yaml",
|
||||||
content: `
|
content: `
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
@@ -95,9 +95,9 @@ jobs:
|
|||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
case "./.github/workflows/reusable-workflow-with-outputs.yaml":
|
case "./reusable-workflow-with-outputs.yaml":
|
||||||
return {
|
return {
|
||||||
name: ".github/workflows/reusable-workflow-with-outputs.yaml",
|
name: "reusable-workflow-with-outputs.yaml",
|
||||||
content: `
|
content: `
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|||||||
@@ -4,10 +4,28 @@ 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 {parseActionReference} from "./action";
|
import { ActionReference, parseActionReference } from "./action";
|
||||||
import { mapRange } from "./utils/range";
|
import { mapRange } from "./utils/range";
|
||||||
import { ValidationConfig } from "./validate";
|
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(
|
export async function validateAction(
|
||||||
diagnostics: Diagnostic[],
|
diagnostics: Diagnostic[],
|
||||||
stepToken: TemplateToken,
|
stepToken: TemplateToken,
|
||||||
@@ -83,10 +101,47 @@ export async function validateAction(
|
|||||||
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(", ")}`;
|
||||||
|
|
||||||
|
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({
|
diagnostics.push({
|
||||||
severity: DiagnosticSeverity.Error,
|
severity: DiagnosticSeverity.Error,
|
||||||
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,7 +249,28 @@ jobs:
|
|||||||
line: 7
|
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
|
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
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -635,7 +635,7 @@ jobs:
|
|||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
node: [14, 16]
|
node: [14, 16]
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
uses: ./reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
username: User-\${{ strategy.fail-fast }}
|
username: User-\${{ strategy.fail-fast }}
|
||||||
`;
|
`;
|
||||||
@@ -654,7 +654,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: [14, 16]
|
node: [14, 16]
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
uses: ./reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
username: \${{ matrix.node }}
|
username: \${{ matrix.node }}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ jobs:
|
|||||||
|
|
||||||
expect(result.length).toBe(1);
|
expect(result.length).toBe(1);
|
||||||
expect(result[0]).toEqual({
|
expect(result[0]).toEqual({
|
||||||
message: "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
|
message: "Invalid cron string",
|
||||||
range: {
|
range: {
|
||||||
end: {
|
end: {
|
||||||
character: 21,
|
character: 21,
|
||||||
@@ -195,95 +195,6 @@ jobs:
|
|||||||
} as Diagnostic);
|
} as Diagnostic);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cron with interval less than 5 minutes shows warning", async () => {
|
|
||||||
const result = await validate(
|
|
||||||
createDocument(
|
|
||||||
"wf.yaml",
|
|
||||||
`on:
|
|
||||||
schedule:
|
|
||||||
- cron: '*/1 * * * *'
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest`
|
|
||||||
),
|
|
||||||
{valueProviderConfig: defaultValueProviders}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.length).toBe(1);
|
|
||||||
expect(result[0]).toEqual({
|
|
||||||
message: "Runs every minute. Note: Actions schedules run at most every 5 minutes.",
|
|
||||||
severity: DiagnosticSeverity.Warning,
|
|
||||||
code: "on-schedule",
|
|
||||||
codeDescription: {
|
|
||||||
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
|
|
||||||
},
|
|
||||||
range: {
|
|
||||||
end: {
|
|
||||||
character: 25,
|
|
||||||
line: 2
|
|
||||||
},
|
|
||||||
start: {
|
|
||||||
character: 12,
|
|
||||||
line: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as Diagnostic);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cron with interval of 5 minutes or more shows info", async () => {
|
|
||||||
const result = await validate(
|
|
||||||
createDocument(
|
|
||||||
"wf.yaml",
|
|
||||||
`on:
|
|
||||||
schedule:
|
|
||||||
- cron: '*/5 * * * *'
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest`
|
|
||||||
),
|
|
||||||
{valueProviderConfig: defaultValueProviders}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.length).toBe(1);
|
|
||||||
expect(result[0]).toEqual({
|
|
||||||
message: "Runs every 5 minutes",
|
|
||||||
severity: DiagnosticSeverity.Information,
|
|
||||||
code: "on-schedule",
|
|
||||||
codeDescription: {
|
|
||||||
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
|
|
||||||
},
|
|
||||||
range: {
|
|
||||||
end: {
|
|
||||||
character: 25,
|
|
||||||
line: 2
|
|
||||||
},
|
|
||||||
start: {
|
|
||||||
character: 12,
|
|
||||||
line: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as Diagnostic);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cron with comma-separated minutes less than 5 apart shows warning", async () => {
|
|
||||||
const result = await validate(
|
|
||||||
createDocument(
|
|
||||||
"wf.yaml",
|
|
||||||
`on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0,2 * * * *'
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest`
|
|
||||||
),
|
|
||||||
{valueProviderConfig: defaultValueProviders}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.length).toBe(1);
|
|
||||||
expect(result[0]?.severity).toBe(DiagnosticSeverity.Warning);
|
|
||||||
expect(result[0]?.message).toContain("Note: Actions schedules run at most every 5 minutes.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invalid YAML", async () => {
|
it("invalid YAML", async () => {
|
||||||
// This YAML has some mismatched single-quotes, which causes the string to be terminated early
|
// This YAML has some mismatched single-quotes, which causes the string to be terminated early
|
||||||
// within the fromJSON() expression.
|
// within the fromJSON() expression.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {Lexer, Parser, data} from "@actions/expressions";
|
|||||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||||
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
|
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, 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 {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||||
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
|
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
|
||||||
@@ -28,9 +27,6 @@ import {validateAction} from "./validate-action";
|
|||||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||||
import {defaultValueProviders} from "./value-providers/default";
|
import {defaultValueProviders} from "./value-providers/default";
|
||||||
|
|
||||||
const CRON_SCHEDULE_DOCS_URL =
|
|
||||||
"https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule";
|
|
||||||
|
|
||||||
export type ValidationConfig = {
|
export type ValidationConfig = {
|
||||||
valueProviderConfig?: ValueProviderConfig;
|
valueProviderConfig?: ValueProviderConfig;
|
||||||
contextProviderConfig?: ContextProviderConfig;
|
contextProviderConfig?: ContextProviderConfig;
|
||||||
@@ -147,34 +143,11 @@ async function additionalValidations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate step uses field format
|
|
||||||
if (isString(token) && token.range && validationDefinition?.key === "step-uses") {
|
|
||||||
validateStepUsesFormat(diagnostics, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate action metadata (inputs, required fields) for regular steps
|
|
||||||
if (token.definition?.key === "regular-step" && token.range) {
|
if (token.definition?.key === "regular-step" && token.range) {
|
||||||
const context = getProviderContext(documentUri, template, root, token.range);
|
const context = getProviderContext(documentUri, template, root, token.range);
|
||||||
await validateAction(diagnostics, token, context.step, config);
|
await validateAction(diagnostics, token, context.step, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate job-level reusable workflow uses field format
|
|
||||||
if (
|
|
||||||
isString(token) &&
|
|
||||||
token.range &&
|
|
||||||
key &&
|
|
||||||
isString(key) &&
|
|
||||||
key.value === "uses" &&
|
|
||||||
parent?.definition?.key === "workflow-job"
|
|
||||||
) {
|
|
||||||
validateWorkflowUsesFormat(diagnostics, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate cron expressions - warn if interval is less than 5 minutes
|
|
||||||
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
|
|
||||||
validateCronExpression(diagnostics, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allowed values coming from the schema have already been validated. Only check if
|
// Allowed values coming from the schema have already been validated. Only check if
|
||||||
// a value provider is defined for a token and if it is, validate the values match.
|
// a value provider is defined for a token and if it is, validate the values match.
|
||||||
if (token.range && validationDefinition) {
|
if (token.range && validationDefinition) {
|
||||||
@@ -225,357 +198,6 @@ function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: Value
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates cron expressions and provides diagnostics for valid cron schedules.
|
|
||||||
* Shows a warning if the interval is less than 5 minutes (since GitHub Actions
|
|
||||||
* schedules run at most every 5 minutes), otherwise shows an info message.
|
|
||||||
*/
|
|
||||||
function validateCronExpression(diagnostics: Diagnostic[], token: StringToken): void {
|
|
||||||
const cronValue = token.value;
|
|
||||||
|
|
||||||
// Ensure we have a range for diagnostics
|
|
||||||
if (!token.range) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only check valid cron expressions - invalid ones are already caught by the parser
|
|
||||||
const description = getCronDescription(cronValue);
|
|
||||||
if (!description) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the cron specifies an interval less than 5 minutes
|
|
||||||
if (hasCronIntervalLessThan5Minutes(cronValue)) {
|
|
||||||
diagnostics.push({
|
|
||||||
message: `${description}. Note: Actions schedules run at most every 5 minutes.`,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
severity: DiagnosticSeverity.Warning,
|
|
||||||
code: "on-schedule",
|
|
||||||
codeDescription: {
|
|
||||||
href: CRON_SCHEDULE_DOCS_URL
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Show info message for valid cron expressions
|
|
||||||
diagnostics.push({
|
|
||||||
message: description,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
severity: DiagnosticSeverity.Information,
|
|
||||||
code: "on-schedule",
|
|
||||||
codeDescription: {
|
|
||||||
href: CRON_SCHEDULE_DOCS_URL
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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).
|
|
||||||
*
|
|
||||||
* Valid formats:
|
|
||||||
* - {owner}/{repo}/.github/workflows/{filename}.yml@{ref}
|
|
||||||
* - {owner}/{repo}/.github/workflows/{filename}.yaml@{ref}
|
|
||||||
* - {owner}/{repo}/.github/workflows-lab/{filename}.yml@{ref}
|
|
||||||
* - {owner}/{repo}/.github/workflows-lab/{filename}.yaml@{ref}
|
|
||||||
* - ./.github/workflows/{filename}.yml
|
|
||||||
* - ./.github/workflows/{filename}.yaml
|
|
||||||
* - ./.github/workflows-lab/{filename}.yml
|
|
||||||
* - ./.github/workflows-lab/{filename}.yaml
|
|
||||||
*/
|
|
||||||
function validateWorkflowUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
|
||||||
const uses = token.value;
|
|
||||||
|
|
||||||
// Local workflow reference
|
|
||||||
if (uses.startsWith("./.github/workflows/") || uses.startsWith("./.github/workflows-lab/")) {
|
|
||||||
// Cannot have @ version for local workflows
|
|
||||||
if (uses.includes("@")) {
|
|
||||||
addWorkflowUsesFormatError(diagnostics, token, "cannot specify version when calling local workflows");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must have .yml or .yaml extension
|
|
||||||
if (!uses.endsWith(".yml") && !uses.endsWith(".yaml")) {
|
|
||||||
addWorkflowUsesFormatError(
|
|
||||||
diagnostics,
|
|
||||||
token,
|
|
||||||
"workflow file should have either a '.yml' or '.yaml' file extension"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must be at top level of .github/workflows/ or .github/workflows-lab/ (no subdirectories)
|
|
||||||
const pathParts = uses.split("/");
|
|
||||||
if (pathParts.length !== 4) {
|
|
||||||
// Expected: ".", ".github", "workflows" or "workflows-lab", "filename.yml"
|
|
||||||
addWorkflowUsesFormatError(
|
|
||||||
diagnostics,
|
|
||||||
token,
|
|
||||||
"workflows must be defined at the top level of the .github/workflows/ directory"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filename cannot be just the extension
|
|
||||||
const filename = pathParts[3];
|
|
||||||
if (filename === ".yml" || filename === ".yaml") {
|
|
||||||
addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Malformed local workflow reference (starts with ./ but not in .github/workflows)
|
|
||||||
if (uses.startsWith("./")) {
|
|
||||||
addWorkflowUsesFormatError(diagnostics, token, "local workflow references must be rooted in '.github/workflows'");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote workflow reference: must have @ for version
|
|
||||||
const atSegments = uses.split("@");
|
|
||||||
if (atSegments.length === 1) {
|
|
||||||
addWorkflowUsesFormatError(diagnostics, token, "no version specified");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (atSegments.length > 2) {
|
|
||||||
addWorkflowUsesFormatError(diagnostics, token, "too many '@' in workflow reference");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [pathPart, version] = atSegments;
|
|
||||||
|
|
||||||
// Version cannot be empty
|
|
||||||
if (!version) {
|
|
||||||
addWorkflowUsesFormatError(diagnostics, token, "no version specified");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must contain .github/workflows or .github/workflows-lab path
|
|
||||||
const workflowsMatch = pathPart.match(/\.github\/workflows(-lab)?\//);
|
|
||||||
if (!workflowsMatch || workflowsMatch.index === undefined) {
|
|
||||||
addWorkflowUsesFormatError(diagnostics, token, "references to workflows must be rooted in '.github/workflows'");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split to get owner/repo and path
|
|
||||||
const pathIdx = workflowsMatch.index;
|
|
||||||
const nwoPart = pathPart.substring(0, pathIdx);
|
|
||||||
const workflowPath = pathPart.substring(pathIdx);
|
|
||||||
|
|
||||||
// Validate NWO part: must be owner/repo/
|
|
||||||
const nwoSegments = nwoPart.split("/").filter(s => s.length > 0);
|
|
||||||
if (nwoSegments.length !== 2) {
|
|
||||||
addWorkflowUsesFormatError(
|
|
||||||
diagnostics,
|
|
||||||
token,
|
|
||||||
"references to workflows must be prefixed with format 'owner/repository/' or './' for local workflows"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate owner and repo names
|
|
||||||
const [owner, repo] = nwoSegments;
|
|
||||||
const nwoError = validateNWO(owner, repo);
|
|
||||||
if (nwoError) {
|
|
||||||
addWorkflowUsesFormatError(diagnostics, token, nwoError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate ref/version format
|
|
||||||
const refError = validateRefName(version);
|
|
||||||
if (refError) {
|
|
||||||
addWorkflowUsesFormatError(diagnostics, token, refError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate workflow path is at top level
|
|
||||||
const workflowPathParts = workflowPath.split("/");
|
|
||||||
if (workflowPathParts.length !== 3) {
|
|
||||||
// Expected: ".github", "workflows" or "workflows-lab", "filename.yml"
|
|
||||||
addWorkflowUsesFormatError(
|
|
||||||
diagnostics,
|
|
||||||
token,
|
|
||||||
"workflows must be defined at the top level of the .github/workflows/ directory"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must have .yml or .yaml extension
|
|
||||||
const filename = workflowPathParts[2];
|
|
||||||
if (!filename.endsWith(".yml") && !filename.endsWith(".yaml")) {
|
|
||||||
addWorkflowUsesFormatError(
|
|
||||||
diagnostics,
|
|
||||||
token,
|
|
||||||
"workflow file should have either a '.yml' or '.yaml' file extension"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filename cannot be just the extension
|
|
||||||
if (filename === ".yml" || filename === ".yaml") {
|
|
||||||
addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addWorkflowUsesFormatError(diagnostics: Diagnostic[], token: StringToken, reason: string): void {
|
|
||||||
diagnostics.push({
|
|
||||||
message: `Invalid workflow reference '${token.value}': ${reason}`,
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the git ref/version format.
|
|
||||||
* Based on Launch's ValidateRefName function.
|
|
||||||
*/
|
|
||||||
function validateRefName(refname: string): string | undefined {
|
|
||||||
if (refname.length === 0) {
|
|
||||||
return "no version specified";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot be the single character '@'
|
|
||||||
if (refname === "@") {
|
|
||||||
return "version cannot be the single character '@'";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot have certain invalid characters or sequences
|
|
||||||
const invalidSequences = ["?", "*", "[", "]", "\\", "~", "^", ":", "@{", "..", "//"];
|
|
||||||
for (const seq of invalidSequences) {
|
|
||||||
if (refname.includes(seq)) {
|
|
||||||
return `invalid character '${seq}' in version`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot begin or end with a slash '/' or a dot '.'
|
|
||||||
if (refname.startsWith("/") || refname.endsWith("/") || refname.startsWith(".") || refname.endsWith(".")) {
|
|
||||||
return "version cannot begin or end with a slash '/' or a dot '.'";
|
|
||||||
}
|
|
||||||
|
|
||||||
// No slash-separated component can begin with a dot '.' or end with the sequence '.lock'
|
|
||||||
const components = refname.split("/");
|
|
||||||
for (const component of components) {
|
|
||||||
if (component.startsWith(".") || component.endsWith(".lock")) {
|
|
||||||
return `invalid version: ${refname}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No ASCII control characters or whitespace
|
|
||||||
// eslint-disable-next-line no-control-regex
|
|
||||||
if (/[\x00-\x1f\x7f]/.test(refname)) {
|
|
||||||
return "version cannot have ASCII control characters";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/\s/.test(refname)) {
|
|
||||||
return "version cannot have whitespace";
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates owner and repository names.
|
|
||||||
* Based on Launch's ValidateNWO function.
|
|
||||||
*/
|
|
||||||
function validateNWO(owner: string, repo: string): string | undefined {
|
|
||||||
// Owner name: can have word chars, dots, and hyphens
|
|
||||||
// \w in JS regex is [a-zA-Z0-9_]
|
|
||||||
if (!/^[\w.-]+$/.test(owner)) {
|
|
||||||
return "owner name must be a valid repository owner name";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repository name: can have word chars, dots, and hyphens
|
|
||||||
if (!/^[\w.-]+$/.test(repo)) {
|
|
||||||
return "repository name is invalid";
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProviderContext(
|
function getProviderContext(
|
||||||
documentUri: URI,
|
documentUri: URI,
|
||||||
template: WorkflowTemplate,
|
template: WorkflowTemplate,
|
||||||
|
|||||||
@@ -1,894 +0,0 @@
|
|||||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
|
||||||
import {validate} from "./validate";
|
|
||||||
import {createDocument} from "./test-utils/document";
|
|
||||||
import {clearCache} from "./utils/workflow-cache";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clearCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("validate uses format", () => {
|
|
||||||
describe("valid formats", () => {
|
|
||||||
it("standard org/repo@ref", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("org/repo with path @ref", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/aws/ec2@main
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("org/repo with deep path @ref", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/aws/nested/deep/path@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("docker image", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: docker://alpine:3.8
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("docker image with registry", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: docker://gcr.io/my-project/my-image:latest
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("local path with ./", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: ./my-action
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("local path with ./ and subdirectories", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: ./.github/actions/my-action
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("local path with .\\ (Windows)", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: .\\my-action
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SHA ref", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("branch ref", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: owner/repo@feature/my-branch
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("invalid formats", () => {
|
|
||||||
it("missing @ref", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 5, character: 12},
|
|
||||||
end: {line: 5, character: 28}
|
|
||||||
},
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty ref", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout@'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 5, character: 12},
|
|
||||||
end: {line: 5, character: 29}
|
|
||||||
},
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("missing org/owner", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: checkout@v4
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'checkout@v4'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 5, character: 12},
|
|
||||||
end: {line: 5, character: 23}
|
|
||||||
},
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty owner", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: /repo@v4
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual '/repo@v4'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 5, character: 12},
|
|
||||||
end: {line: 5, character: 20}
|
|
||||||
},
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty repo", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: owner/@v4
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'owner/@v4'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 5, character: 12},
|
|
||||||
end: {line: 5, character: 21}
|
|
||||||
},
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("multiple @ symbols", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4@extra
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout@v4@extra'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 5, character: 12},
|
|
||||||
end: {line: 5, character: 37}
|
|
||||||
},
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("just a name with no slash", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: checkout
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'checkout'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 5, character: 12},
|
|
||||||
end: {line: 5, character: 20}
|
|
||||||
},
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty uses value", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: ""
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toContainEqual({
|
|
||||||
message: "`uses' value in action cannot be blank",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 5, character: 12},
|
|
||||||
end: {line: 5, character: 14}
|
|
||||||
},
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reusable workflow in step", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: owner/repo/.github/workflows/test.yml@main
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 5, character: 12},
|
|
||||||
end: {line: 5, character: 54}
|
|
||||||
},
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("workflow uses format validation", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
clearCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("valid formats", () => {
|
|
||||||
it("local workflow path", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: ./.github/workflows/test.yml
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("local workflow path with yaml extension", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: ./.github/workflows/test.yaml
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("remote workflow with version", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("remote workflow with sha ref", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml@abc123
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("remote workflow with branch ref", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml@main
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("remote workflow with yaml extension", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yaml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("local workflows-lab path", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: ./.github/workflows-lab/test.yml
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("local workflows-lab path with yaml extension", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: ./.github/workflows-lab/test.yaml
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("remote workflows-lab with version", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows-lab/test.yml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("invalid formats", () => {
|
|
||||||
it("remote workflow missing version", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Invalid workflow reference 'owner/repo/.github/workflows/test.yml': no version specified",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 47}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("local workflow with version", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: ./.github/workflows/test.yml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference './.github/workflows/test.yml@v1': cannot specify version when calling local workflows",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 41}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("malformed local path not in .github/workflows", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: ./foo/bar.yml
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference './foo/bar.yml': local workflow references must be rooted in '.github/workflows'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 23}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("missing .github/workflows path", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/test.yml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo/test.yml@v1': references to workflows must be rooted in '.github/workflows'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 32}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invalid file extension", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.txt@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.txt@v1': workflow file should have either a '.yml' or '.yaml' file extension",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 50}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("no extension", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo/.github/workflows/test@v1': workflow file should have either a '.yml' or '.yaml' file extension",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 46}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("just a ref", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: test.yml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'test.yml@v1': references to workflows must be rooted in '.github/workflows'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 21}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("local without .github/workflows", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: ./workflows/test.yml
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference './workflows/test.yml': local workflow references must be rooted in '.github/workflows'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 30}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("invalid ref/version format", () => {
|
|
||||||
it("empty version after @", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml@
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@': no version specified",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 48}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("version with invalid character ?", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml@v1?
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1?': invalid character '?' in version",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 51}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("version with double dots", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/[email protected]
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo/.github/workflows/[email protected]': invalid character '..' in version",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 54}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("version ending with dot", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml@v1.
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1.': version cannot begin or end with a slash '/' or a dot '.'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 51}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("version starting with slash", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml@/v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@/v1': version cannot begin or end with a slash '/' or a dot '.'",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 51}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("version ending with .lock", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml@refs/heads/main.lock
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@refs/heads/main.lock': invalid version: refs/heads/main.lock",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 68}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("version with whitespace", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml@v1 && rm -rf
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1 && rm -rf': version cannot have whitespace",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 60}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("version with backslash", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/test.yml@v1\\1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1\\1': invalid character '\\' in version",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 52}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("invalid owner/repo names", () => {
|
|
||||||
it("owner with invalid characters", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner*/repo/.github/workflows/test.yml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner*/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 51}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("repo with invalid characters", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo!name/.github/workflows/test.yml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner/repo!name/.github/workflows/test.yml@v1': repository name is invalid",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 55}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("owner with spaces", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner name/repo/.github/workflows/test.yml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid workflow reference 'owner name/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 55}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("invalid workflow filename", () => {
|
|
||||||
it("filename is just .yml", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/.yml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yml@v1': invalid workflow file name",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 46}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filename is just .yaml", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: owner/repo/.github/workflows/.yaml@v1
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yaml@v1': invalid workflow file name",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 47}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("local workflow filename is just .yml", async () => {
|
|
||||||
const input = `on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
uses: ./.github/workflows/.yml
|
|
||||||
`;
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
message: "Invalid workflow reference './.github/workflows/.yml': invalid workflow file name",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: {
|
|
||||||
start: {line: 3, character: 10},
|
|
||||||
end: {line: 3, character: 34}
|
|
||||||
},
|
|
||||||
code: "invalid-workflow-uses-format"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -43,7 +43,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: monalisa/octocat/.github/workflows/workflow.yaml@not-a-branch
|
uses: monalisa/octocat/workflow.yaml@not-a-branch
|
||||||
`;
|
`;
|
||||||
const result = await validate(createDocument("wf.yaml", input), {
|
const result = await validate(createDocument("wf.yaml", input), {
|
||||||
fileProvider: testFileProvider
|
fileProvider: testFileProvider
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
line: 5
|
line: 5
|
||||||
},
|
},
|
||||||
end: {
|
end: {
|
||||||
character: 71,
|
character: 53,
|
||||||
line: 5
|
line: 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: monalisa/octocat/.github/workflows/workflow.yaml@main
|
uses: monalisa/octocat/workflow.yaml@main
|
||||||
`;
|
`;
|
||||||
const result = await validate(createDocument("wf.yaml", input), {
|
const result = await validate(createDocument("wf.yaml", input), {
|
||||||
fileProvider: testFileProvider
|
fileProvider: testFileProvider
|
||||||
@@ -87,7 +87,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow.yaml
|
uses: ./reusable-workflow.yaml
|
||||||
`;
|
`;
|
||||||
const result = await validate(createDocument("wf.yaml", input), {
|
const result = await validate(createDocument("wf.yaml", input), {
|
||||||
fileProvider: testFileProvider
|
fileProvider: testFileProvider
|
||||||
@@ -102,7 +102,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
uses: ./reusable-workflow-with-inputs.yaml
|
||||||
secrets:
|
secrets:
|
||||||
envPAT: pat
|
envPAT: pat
|
||||||
`;
|
`;
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
line: 5
|
line: 5
|
||||||
},
|
},
|
||||||
end: {
|
end: {
|
||||||
character: 64,
|
character: 46,
|
||||||
line: 5
|
line: 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
uses: ./reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
username: monalisa
|
username: monalisa
|
||||||
secrets:
|
secrets:
|
||||||
|
|||||||
+1
-1
@@ -6,5 +6,5 @@
|
|||||||
"languageservice",
|
"languageservice",
|
||||||
"languageserver"
|
"languageserver"
|
||||||
],
|
],
|
||||||
"version": "0.3.21"
|
"version": "0.3.20"
|
||||||
}
|
}
|
||||||
Generated
+667
-121
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/workflow-parser",
|
"name": "@actions/workflow-parser",
|
||||||
"version": "0.3.21",
|
"version": "0.3.20",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.21",
|
"@actions/expressions": "^0.3.20",
|
||||||
"cronstrue": "^2.21.0",
|
"cronstrue": "^2.21.0",
|
||||||
"yaml": "^2.0.0-8"
|
"yaml": "^2.0.0-8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {isValidCron, getCronDescription, hasCronIntervalLessThan5Minutes} from "./cron";
|
import {isValidCron, getCronDescription} from "./cron";
|
||||||
|
|
||||||
describe("cron", () => {
|
describe("cron", () => {
|
||||||
describe("valid cron", () => {
|
describe("valid cron", () => {
|
||||||
@@ -66,54 +66,14 @@ describe("cron", () => {
|
|||||||
|
|
||||||
describe("getCronDescription", () => {
|
describe("getCronDescription", () => {
|
||||||
it(`Produces a sentence for valid cron`, () => {
|
it(`Produces a sentence for valid cron`, () => {
|
||||||
expect(getCronDescription("0 * * * *")).toEqual("Runs every hour");
|
expect(getCronDescription("0 * * * *")).toEqual(
|
||||||
|
"Runs every hour\n\n" +
|
||||||
|
"Actions schedules run at most every 5 minutes. [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`Returns nothing for invalid cron`, () => {
|
it(`Returns nothing for invalid cron`, () => {
|
||||||
expect(getCronDescription("* * * * * *")).toBeUndefined();
|
expect(getCronDescription("* * * * * *")).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("hasCronIntervalLessThan5Minutes", () => {
|
|
||||||
it("returns true for step expressions with interval < 5 min", () => {
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("*/1 * * * *")).toBe(true);
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("*/4 * * * *")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for step expressions with interval >= 5 min", () => {
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("*/5 * * * *")).toBe(false);
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("*/15 * * * *")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for comma-separated values with gap < 5 min", () => {
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("0,2,4 * * * *")).toBe(true);
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("0,10,12 * * * *")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for comma-separated values with gap >= 5 min", () => {
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("0,10,20 * * * *")).toBe(false);
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("0,30 * * * *")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for comma-separated values with wrap-around gap < 5 min", () => {
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("0,58 * * * *")).toBe(true);
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("2,59 * * * *")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for * (every minute)", () => {
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("* * * * *")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for range expressions (runs every minute in range)", () => {
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("0-4 * * * *")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for single value (hourly)", () => {
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("0 * * * *")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for invalid cron", () => {
|
|
||||||
expect(hasCronIntervalLessThan5Minutes("invalid")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,78 +8,6 @@ type Range = {
|
|||||||
names?: Record<string, number>;
|
names?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a cron expression specifies an interval shorter than 5 minutes.
|
|
||||||
* GitHub Actions schedules run at most every 5 minutes, so intervals < 5 min won't work as expected.
|
|
||||||
*/
|
|
||||||
export function hasCronIntervalLessThan5Minutes(cron: string): boolean {
|
|
||||||
if (!isValidCron(cron)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = cron.split(/ +/);
|
|
||||||
const minutePart = parts[0];
|
|
||||||
|
|
||||||
// Parse the minute field to determine the effective interval
|
|
||||||
return getMinuteInterval(minutePart) < 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the minimum interval in minutes between cron executions based on the minute field.
|
|
||||||
* Returns 60 if there's only one execution per hour, otherwise returns the minimum gap.
|
|
||||||
*/
|
|
||||||
function getMinuteInterval(minutePart: string): number {
|
|
||||||
// Handle step expressions like */1, */3, 0-59/2
|
|
||||||
if (minutePart.includes("/")) {
|
|
||||||
const [, step] = minutePart.split("/");
|
|
||||||
const stepNum = parseInt(step, 10);
|
|
||||||
if (!isNaN(stepNum) && stepNum > 0) {
|
|
||||||
return stepNum;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle comma-separated values like 0,2,4 or 0,1,5,10
|
|
||||||
if (minutePart.includes(",")) {
|
|
||||||
const values = minutePart
|
|
||||||
.split(",")
|
|
||||||
.map(v => parseInt(v, 10))
|
|
||||||
.filter(n => !isNaN(n))
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
if (values.length >= 2) {
|
|
||||||
let minGap = 60;
|
|
||||||
for (let i = 1; i < values.length; i++) {
|
|
||||||
const gap = values[i] - values[i - 1];
|
|
||||||
if (gap < minGap) {
|
|
||||||
minGap = gap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check wrap-around gap from last minute to first minute of next hour
|
|
||||||
const wrapGap = values[0] + 60 - values[values.length - 1];
|
|
||||||
if (wrapGap < minGap) {
|
|
||||||
minGap = wrapGap;
|
|
||||||
}
|
|
||||||
return minGap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle range expressions like 0-4 (runs every minute from 0-4)
|
|
||||||
if (minutePart.includes("-") && !minutePart.includes("/")) {
|
|
||||||
const [start, end] = minutePart.split("-").map(v => parseInt(v, 10));
|
|
||||||
if (!isNaN(start) && !isNaN(end) && end > start) {
|
|
||||||
// A range without step means every minute in that range
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// * means every minute
|
|
||||||
if (minutePart === "*") {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single value or unrecognized pattern - assume hourly (60 min interval)
|
|
||||||
return 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidCron(cron: string): boolean {
|
export function isValidCron(cron: string): boolean {
|
||||||
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
||||||
|
|
||||||
@@ -118,7 +46,11 @@ export function getCronDescription(cronspec: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make first character lowercase
|
// Make first character lowercase
|
||||||
return "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
|
let result = "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
|
||||||
|
result +=
|
||||||
|
"\n\nActions schedules run at most every 5 minutes." +
|
||||||
|
" [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)";
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateCronPart(value: string, range: Range, allowSeparators = true): boolean {
|
function validateCronPart(value: string, range: Range, allowSeparators = true): boolean {
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ function convertSchedule(context: TemplateContext, token: SequenceToken): Schedu
|
|||||||
const cron = schedule.value.assertString(`schedule cron`);
|
const cron = schedule.value.assertString(`schedule cron`);
|
||||||
// Validate the cron string
|
// Validate the cron string
|
||||||
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 string");
|
||||||
}
|
}
|
||||||
result.push({cron: cron.value});
|
result.push({cron: cron.value});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user