Compare commits

..

24 Commits

Author SHA1 Message Date
Salman Chishti 5abd234cbf Merge branch 'main' into node24 2025-12-12 21:27:29 +00:00
github-actions[bot] 742b36d6b7 Release extension version 0.3.25 (#248)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-08 13:51:17 -06:00
eric sciple 8507419ebf Add missing activity types for pull_request and pull_request_target (#242)
Fixes #51

Added the following activity types to pull_request and pull_request_target:
- milestoned
- demilestoned
- enqueued
- dequeued

These types were missing from workflow-v1.0.json but are valid workflow
triggers per GitHub docs.

Also added schema-sync.test.ts to ensure activity types in workflow-v1.0.json
stay in sync with webhooks.json. The test:
- Checks both directions (webhooks→schema and schema→webhooks)
- Has WEBHOOK_ONLY for types not valid as workflow triggers:
  - check_suite: requested, rerequested
  - registry_package: default
- Has SCHEMA_ONLY for types valid in workflows but not in webhooks:
  - registry_package: updated
- Has NAME_MAPPINGS for naming differences:
  - project_column: edited (webhook) ↔ updated (schema)
- Provides actionable error messages when mismatches are found
2025-12-08 13:44:56 -06:00
github-actions[bot] 952dc89b78 Release extension version 0.3.24 (#247)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-08 10:06:11 -06:00
eric sciple 2934e36944 Allow empty strings in workflow_dispatch choice options (#245)
Fixes vscode#395 - Empty value for choice option shows 'Unexpected value' error

Empty strings are valid options for workflow_dispatch inputs with type: choice.
They allow users to make a choice 'optional' or force explicit selection.

Changes:
- Add sequence-of-string type that allows empty strings (unlike sequence-of-non-empty-string)
- Use sequence-of-string for workflow_dispatch options field
- Add test to verify empty string in choice options doesn't produce validation errors
2025-12-08 09:25:51 -06:00
eric sciple 8d2c24d7f5 Add missing runner context properties (environment, debug, workspace) (#241)
Fixes #78, #121

Adds three missing properties to the runner context:
- runner.environment: The runner environment (github-hosted or self-hosted)
- runner.debug: Set to '1' when step debug logging is enabled via ACTIONS_STEP_DEBUG
- runner.workspace: The runner-specific working directory path for the job

These are documented official properties that were causing false 'Context access might be invalid' warnings.
2025-12-08 09:22:49 -06:00
eric sciple 4181cb3c90 Fix expression completion in multi-line if block scalars (#238)
Fixed the cursor offset calculation for multi-line strings. The original
code unconditionally added +1 for a newline separator, but when the cursor
is on the first content line, there are no lines before it, so adding +1
produced an off-by-one error.

Fixes: vscode-github-actions#81
2025-12-08 09:20:50 -06:00
eric sciple 78ea3ba17f Add validation for concurrency deadlock detection (#237)
This adds an error when workflow-level and job-level concurrency groups
match, which causes a deadlock at runtime. The job blocks waiting for
the workflow to finish, while the workflow is waiting for the job to finish.

- Detects both string and mapping forms of concurrency
- Only errors on static string matches (expressions are not compared)
- Case-sensitive comparison
- Errors on both workflow-level and job-level with appropriate messages

Fixes #135
2025-12-08 09:20:22 -06:00
eric sciple 4cf3365c68 Suppress warnings for step output property access (#236)
Fixes https://github.com/github/vscode-github-actions/issues/305

Step outputs are dynamic - actions can generate outputs based on
their inputs, so validating output property names is not feasible.

This marks step output dictionaries as incomplete so that accessing
any output property won't produce a warning. Known outputs from
action.yml will still be suggested for autocomplete.
2025-12-08 09:20:02 -06:00
eric sciple 1a63ee9de6 fix: always provide strategy and matrix contexts with defaults (#235)
Fixes https://github.com/github/vscode-github-actions/issues/113

The strategy and matrix contexts are always available in job steps,
even when no strategy block is defined.

Changes:
- Remove the hasStrategy filter from filterContextNames in default.ts
- Return null for matrix when no strategy is defined
- Provide default values for strategy properties:
  - fail-fast: true
  - job-index: 0
  - job-total: 1
  - max-parallel: 1
- Use defaults for missing strategy properties even when strategy IS defined
- Add comprehensive unit tests for strategy context

This eliminates false positive 'Context access might be invalid'
warnings when using strategy.* or matrix in jobs without an
explicit strategy block.
2025-12-08 09:19:40 -06:00
eric sciple 108b8c2766 Support YAML anchors and aliases (#234)
Fixes https://github.com/github/vscode-github-actions/issues/405

YAML anchors (&name) and aliases (*name) are now properly supported.
When an alias is encountered during parsing, it is resolved to its
anchored value, making aliases transparent to the rest of the system.

Changes:
- workflow-parser: Handle isAlias nodes in YamlObjectReader.getNodes()
- languageservice: Add tests for various anchor/alias patterns

Test cases:
- Anchors in env mappings
- Multiple aliases to same anchor
- Anchors in matrix strategy
- Anchors in steps
- Scalar anchors (e.g., runs-on)
2025-12-08 09:18:59 -06:00
eric sciple e20dbae803 Skip secrets/vars validation when context is incomplete (#233) 2025-12-08 09:18:18 -06:00
Alex Howard 69b383af3d Skip variable validation for dynamic environments (#178) 2025-12-06 17:38:01 -06:00
eric sciple 4429c41275 Align supported Node.js engines field with dependency requirements (#231) 2025-12-05 15:28:10 -06:00
github-actions[bot] 7b9adb106e Release extension version 0.3.23 (#230)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-05 10:38:55 -06:00
eric sciple 576402fc01 Optimize JSON data files to reduce bundle size by 90% (#229) 2025-12-05 10:27:19 -06:00
github-actions[bot] 22c36bc946 Release extension version 0.3.22 (#228)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-04 13:36:13 -06:00
eric sciple 4dd678cf30 Improve cron schedule warning message (#227) 2025-12-04 13:31:20 -06:00
github-actions[bot] dfb411f71e Release extension version 0.3.21 (#226)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-04 11:48:14 -06:00
eric sciple dec597b0db Improve cron schedule validation and diagnostics (#224) 2025-12-04 11:25:15 -06:00
eric sciple bd7e5f0b70 Fix npm audit vulnerabilities (#222) 2025-12-03 11:57:43 -06:00
eric sciple 37ba6ab105 Fix misleading error for malformed local workflow paths (#221) 2025-12-03 10:40:31 -06:00
eric sciple 216fcbb8c4 Add uses format validation for step and job-level workflows (#220) 2025-12-03 09:44:36 -06:00
Salman Muin Kayser Chishti 751cb5a940 Update action metadata to use node24 runtime
Changed the 'using' field in action metadata YAML from 'node16' to 'node24' in test utilities and tests to reflect the updated Node.js runtime environment.
2025-07-30 15:19:44 +01:00
82 changed files with 30064 additions and 128314 deletions
+40 -3
View File
@@ -12,18 +12,55 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js 16.15
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: 16.15
node-version: ${{ matrix.node-version }}
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
- run: npm ci
- run: npm ci --engine-strict
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm run format-check -ws
- run: npm run build -ws
- run: npm run lint -ws
- run: npm test -ws
check-generated:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Regenerate JSON files
run: |
cd languageservice && npm run update-webhooks && cd ..
- name: Check for uncommitted changes
run: |
if ! git diff --exit-code; then
echo ""
echo "=========================================="
echo "ERROR: Generated files are out of date!"
echo "=========================================="
echo ""
echo "Please run the following commands locally and commit the changes:"
echo ""
echo " cd languageservice && npm run update-webhooks && cd .."
echo " git add -A && git commit -m 'Regenerate JSON files'"
echo ""
exit 1
fi
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 16.x
node-version: 22.x
cache: "npm"
scope: '@actions'
+9 -1
View File
@@ -2,4 +2,12 @@
*/dist
lerna-debug.log
node_modules
.DS_Store
.DS_Store
# Minified JSON (generated at build time)
*.min.json
# Intermediate JSON for size comparison (generated by update-webhooks --all)
*.all.json
*.drop.json
*.strip.json
-152
View File
@@ -1,152 +0,0 @@
# 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" }`)
+4
View File
@@ -8,6 +8,10 @@ This repository contains multiple npm packages for working with GitHub Actions w
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
- [browser-playground](./browser-playground) - Browser-based playground for the language service
## Documentation
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
### Note
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
+197
View File
@@ -0,0 +1,197 @@
# JSON Data Files
This document describes the JSON data files used by the language service packages and how they are maintained.
## Overview
The language service uses several JSON files containing schema definitions, webhook payloads, and other metadata. To reduce bundle size, these files are:
1. **Optimized at generation time** — unused events are dropped, unused fields are stripped
2. **Minified at build time** — whitespace is removed to produce `.min.json` files
The source `.json` files are human-readable and checked into the repository. The `.min.json` files are generated during build and gitignored.
## Files
### languageservice
| File | Description |
|------|-------------|
| `src/context-providers/events/webhooks.json` | Webhook event payload schemas for autocompletion |
| `src/context-providers/events/objects.json` | Deduplicated shared object definitions referenced by webhooks |
| `src/context-providers/events/schedule.json` | Schedule event context data |
| `src/context-providers/events/workflow_call.json` | Reusable workflow call context data |
| `src/context-providers/descriptions.json` | Context variable descriptions for hover |
### workflow-parser
| File | Description |
|------|-------------|
| `src/workflow-v1.0.json` | Workflow YAML schema definition |
## Generation
### Webhooks and Objects
The `webhooks.json` and `objects.json` files are generated from the [GitHub REST API description](https://github.com/github/rest-api-description):
```bash
cd languageservice
npm run update-webhooks
```
This script:
1. Fetches webhook schemas from the GitHub API description
2. **Validates** all events are categorized (fails if new events are found)
3. **Drops** events that aren't valid workflow triggers (see [Dropped Events](#dropped-events))
4. **Strips** unused fields like `description` and `summary` (see [Stripped Fields](#stripped-fields))
5. **Deduplicates** shared object definitions into `objects.json`
6. Writes the optimized, pretty-printed JSON files
### Handling New Webhook Events
When GitHub adds a new webhook event, the script will fail with an error like:
```
ERROR: New webhook event(s) detected!
The following events are not categorized:
- new_event_name
Action required:
1. Check if the event is a valid workflow trigger
2. Add the event to DROPPED_EVENTS or KEPT_EVENTS
```
**To resolve:**
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
2. Edit `languageservice/script/webhooks/index.ts`:
- Add to `KEPT_EVENTS` if it's a valid workflow trigger
- Add to `DROPPED_EVENTS` if it's GitHub App or API-only
3. Run `npm run update-webhooks` and commit the changes
#### Viewing Full Unprocessed Data
To see all available fields and events before optimization:
```bash
npm run update-webhooks -- --all
```
This generates `webhooks.all.json` and `objects.all.json` (gitignored) containing the complete unprocessed data from the GitHub API.
### Other Files
The other JSON files (`schedule.json`, `workflow_call.json`, `descriptions.json`, `workflow-v1.0.json`) are manually maintained.
## Minification
At build time, all JSON files are minified (whitespace removed) to produce `.min.json` versions:
```bash
npm run minify-json
```
This runs automatically via `prebuild` and `pretest` hooks, so you don't need to run it manually.
The code imports the minified versions:
```ts
import webhooks from "./events/webhooks.min.json"
```
## CI Verification
CI verifies that generated source files are up-to-date:
1. Runs `npm run update-webhooks` to regenerate webhooks.json and objects.json
2. Checks for uncommitted changes with `git diff --exit-code`
The `.min.json` files are generated at build time and are not committed to the repository.
If the build fails, run `cd languageservice && npm run update-webhooks` locally and commit the changes.
## Dropped Events
Webhook events that aren't valid workflow `on:` triggers are dropped (e.g., `installation`, `ping`, `member`, etc.). These are GitHub App or API-only events.
See `DROPPED_EVENTS` in `script/webhooks/index.ts` for the full list.
## Stripped Fields
Unused fields are stripped to reduce bundle size. For example:
```json
// Before (from webhooks.all.json)
{
"type": "object",
"name": "issue",
"in": "body",
"description": "The issue itself.",
"isRequired": true,
"childParamsGroups": [...]
}
// After (webhooks.json)
{
"name": "issue",
"description": "The issue itself.",
"childParamsGroups": [...]
}
```
Only `name`, `description`, and `childParamsGroups` are kept — these are used for autocompletion and hover docs.
To compare all fields vs stripped, run `npm run update-webhooks -- --all` and diff the `.all.json` files against the regular ones.
See `EVENT_ACTION_FIELDS` and `BODY_PARAM_FIELDS` in `script/webhooks/index.ts` to modify what gets stripped.
## Schema Synchronization
The `workflow-v1.0.json` schema defines which activity types are valid for each workflow trigger event. A test in `workflow-parser/src/schema-sync.test.ts` verifies these stay in sync with `webhooks.json`.
### When the Test Fails
If the schema-sync test fails, you'll see an error like:
```
Event "pull_request" is missing activity type "new_activity" in workflow-v1.0.json
```
**To resolve:**
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows) to verify the activity type is a valid workflow trigger:
- Find the event section (e.g., "pull_request")
- Look at the "Activity types" table — it lists which types can be used in `on.<event>.types`
- If the type is listed there, it's a valid workflow trigger
- If the type only appears in webhook docs but NOT in the workflow trigger docs, it's webhook-only
2. If it IS a valid workflow trigger:
- Edit `workflow-parser/src/workflow-v1.0.json`
- Find the `<event>-activity-type` definition (e.g., `pull-request-activity-type`)
- Add the new activity type to `allowed-values`
- Update the `description` in `<event>-activity` to list all types
- Run `npm test` to regenerate the minified JSON
3. If it is NOT a valid workflow trigger (webhook-only):
- Edit `workflow-parser/src/schema-sync.test.ts`
- Add the type to `WEBHOOK_ONLY` for that event
### Known Discrepancies
The test tracks several types of known discrepancies:
| Category | Purpose | Example |
|----------|---------|---------|
| `WEBHOOK_ONLY` | Types in webhooks that aren't valid workflow triggers | `check_suite.requested` |
| `SCHEMA_ONLY` | Types valid for workflows but missing from webhooks | `registry_package.updated` |
| `NAME_MAPPINGS` | Different names for the same concept | `project_column`: webhook uses `edited`, schema uses `updated` |
### Bidirectional Checking
The test checks both directions:
- **webhooks → schema**: Ensures all webhook activity types are in the schema (or listed in `WEBHOOK_ONLY`)
- **schema → webhooks**: Ensures the schema doesn't have types that don't exist in webhooks (or listed in `SCHEMA_ONLY` or `NAME_MAPPINGS`)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.20",
"version": "0.3.25",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -44,7 +44,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"engines": {
"node": ">= 16.15"
"node": ">= 18"
},
"files": [
"dist/**/*"
@@ -1,2 +0,0 @@
#!/usr/bin/env node
import "../dist/cli.bundle.cjs";
+4 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.20",
"version": "0.3.25",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -32,7 +32,6 @@
},
"scripts": {
"build": "tsc --build tsconfig.build.json",
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
@@ -43,12 +42,9 @@
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"watch": "tsc --build tsconfig.build.json --watch"
},
"bin": {
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@actions/languageservice": "^0.3.25",
"@actions/workflow-parser": "^0.3.25",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -56,7 +52,7 @@
"yaml": "^2.1.3"
},
"engines": {
"node": ">= 16.15"
"node": ">= 18"
},
"files": [
"dist/**/*"
@@ -65,7 +61,6 @@
"@types/jest": "^29.0.3",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"esbuild": "^0.27.1",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
-10
View File
@@ -1,10 +0,0 @@
#!/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 -17
View File
@@ -1,11 +1,8 @@
import {documentLinks, getCodeActions, hover, validate, ValidationConfig} from "@actions/languageservice";
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem,
Connection,
DocumentLink,
@@ -75,9 +72,6 @@ export function initConnection(connection: Connection) {
hoverProvider: true,
documentLinkProvider: {
resolveProvider: false
},
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
}
}
};
@@ -164,16 +158,6 @@ export function initConnection(connection: Connection) {
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
});
connection.onCodeAction(async (params: CodeActionParams): Promise<CodeAction[]> => {
return timeOperation("codeAction", async () => {
return getCodeActions({
uri: params.textDocument.uri,
diagnostics: params.context.diagnostics,
only: params.context.only
});
});
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
@@ -0,0 +1,76 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {Mode} from "@actions/languageservice/context-providers/default";
import {contextProviders} from "./context-providers";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
describe("contextProviders", () => {
const mockCache = new TTLCache();
const mockRepo: RepositoryContext = {
id: 123,
owner: "test-owner",
name: "test-repo",
organizationOwned: true,
workspaceUri: "file:///workspace"
};
const mockWorkflowContext: WorkflowContext = {
uri: "test.yaml",
template: undefined
};
describe("when client is undefined", () => {
it("should return incomplete context for secrets", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should return incomplete context for vars", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should preserve defaultContext and mark as incomplete for secrets", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const defaultContext = new DescriptionDictionary();
defaultContext.add("EXISTING_SECRET", new data.StringData("test"));
const result = await config.getContext("secrets", defaultContext, mockWorkflowContext, Mode.Validation);
expect(result).toBe(defaultContext);
expect((result as DescriptionDictionary).complete).toBe(false);
expect((result as DescriptionDictionary).get("EXISTING_SECRET")).toBeDefined();
});
it("should return undefined for other contexts like steps", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("steps", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeUndefined();
});
});
describe("when both client and repo are undefined", () => {
it("should return incomplete context for secrets", async () => {
const config = contextProviders(undefined, undefined, mockCache);
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should return incomplete context for vars", async () => {
const config = contextProviders(undefined, undefined, mockCache);
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
});
});
+12 -1
View File
@@ -15,7 +15,18 @@ export function contextProviders(
cache: TTLCache
): ContextProviderConfig {
if (!repo || !client) {
return {getContext: () => Promise.resolve(undefined)};
// When GitHub client/repo is unavailable, return an incomplete dictionary
// to avoid false "Context access might be invalid" warnings
return {
getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => {
if (name === "secrets" || name === "vars") {
const context = defaultContext || new DescriptionDictionary();
context.complete = false;
return Promise.resolve(context);
}
return Promise.resolve(undefined);
}
};
}
const getContext = async (
@@ -49,7 +49,7 @@ export async function getSecrets(
if (isString(x.value)) {
environmentName = x.value.value;
} else {
// this means we have a dynamic enviornment, in those situations we
// this means we have a dynamic environment, in those situations we
// want to make sure we skip doing secret validation
secretsContext.complete = false;
}
@@ -1,4 +1,4 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
@@ -63,6 +63,43 @@ it("returns default context when job is undefined", async () => {
expect(stepsContext).toEqual(defaultContext);
});
it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
const mock = fetchMock
.sandbox()
.getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata);
const workflowContext = await createWorkflowContext(workflow, "build");
const defaultContext = getDefaultStepsContext(workflowContext);
const stepsContext = await getStepsContext(
new Octokit({
request: {
fetch: mock
}
}),
new TTLCache(),
defaultContext,
workflowContext
);
// Get the step context
const stepContext = stepsContext?.get("cache-primes");
expect(stepContext).toBeDefined();
expect(isDescriptionDictionary(stepContext!)).toBe(true);
// Get the outputs - should be a dictionary, not null
const outputs = (stepContext as DescriptionDictionary).get("outputs");
expect(outputs).toBeDefined();
expect(isDescriptionDictionary(outputs!)).toBe(true);
// Outputs should be marked incomplete to allow dynamic outputs
const outputsDict = outputs as DescriptionDictionary;
expect(outputsDict.complete).toBe(false);
// Known outputs from action.yml should be present
expect(outputsDict.get("cache-hit")).toBeDefined();
});
it("adds action outputs", async () => {
const mock = fetchMock
.sandbox()
@@ -83,17 +120,22 @@ it("adds action outputs", async () => {
);
expect(stepsContext).toBeDefined();
// Create expected outputs dict with complete = false
// (actions can have dynamic outputs beyond what's declared in action.yml)
const expectedOutputs = new DescriptionDictionary({
key: "cache-hit",
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
description: "A boolean value to indicate an exact match was found for the primary key"
});
expectedOutputs.complete = false;
expect(stepsContext).toEqual(
new DescriptionDictionary({
key: "cache-primes",
value: new DescriptionDictionary(
{
key: "outputs",
value: new DescriptionDictionary({
key: "cache-hit",
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
description: "A boolean value to indicate an exact match was found for the primary key"
})
value: expectedOutputs
},
{
key: "conclusion",
@@ -58,6 +58,8 @@ export async function getStepsContext(
continue;
}
const outputsDict = new DescriptionDictionary();
// Actions can have dynamic outputs beyond what's declared in action.yml
outputsDict.complete = false;
for (const [key, value] of Object.entries(outputs)) {
outputsDict.add(key, new data.StringData(value.description), value.description);
}
@@ -26,6 +26,8 @@ export async function getVariables(
return secretsContext;
}
const variablesContext = defaultContext || new DescriptionDictionary();
let environmentName: string | undefined;
if (workflowContext?.job?.environment) {
if (isString(workflowContext.job.environment)) {
@@ -35,14 +37,19 @@ export async function getVariables(
if (isString(x.key) && x.key.value === "name") {
if (isString(x.value)) {
environmentName = x.value.value;
} else {
// this means we have a dynamic environment, in those situations we want to skip validation
variablesContext.complete = false;
}
break;
}
}
} else {
// if the expression is something like environment: ${{ ... }} then we want to skip validation
variablesContext.complete = false;
}
}
const variablesContext = defaultContext || new DescriptionDictionary();
try {
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
+3 -3
View File
@@ -1,12 +1,12 @@
import { Connection } from "vscode-languageserver";
import {Connection} from "vscode-languageserver";
import {
BrowserMessageReader,
BrowserMessageWriter,
createConnection as createBrowserConnection
} from "vscode-languageserver/browser";
import { createConnection as createNodeConnection } from "vscode-languageserver/node";
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
import { initConnection } from "./connection";
import {initConnection} from "./connection";
/** Helper function determining whether we are executing with node runtime */
function isNode(): boolean {
@@ -16,7 +16,7 @@ inputs:
description: 'Repository name with owner. For example, actions/checkout'
deprecationMessage: 'Use repository instead'
runs:
using: node16
using: node24
main: dist/index.js
post: dist/index.js
`;
@@ -12,7 +12,7 @@ inputs:
description: Repository name with owner. For example, actions/checkout
default: \${{ github.repository }}
runs:
using: node16
using: node24
main: dist/index.js
post: dist/index.js
`;
@@ -231,7 +231,7 @@ inputs:
description: 📦 Repository 📦 name with owner. For example, actions/checkout
default: \${{ github.repository }}
runs:
using: node16
using: node24
main: dist/index.js
post: dist/index.js
`;
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.20",
"version": "0.3.25",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -37,22 +37,25 @@
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
"prebuild": "npm run minify-json",
"prepublishOnly": "npm run build && npm run test",
"pretest": "npm run minify-json",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"update-webhooks": "ts-node-esm script/webhooks/index.ts",
"update-webhooks": "npx tsx script/webhooks/index.ts",
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@actions/expressions": "^0.3.25",
"@actions/workflow-parser": "^0.3.25",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
"yaml": "^2.1.1"
},
"engines": {
"node": ">= 16.15"
"node": ">= 18"
},
"files": [
"dist/**/*"
+273 -2
View File
@@ -7,6 +7,185 @@ const schema = schemaImport as any;
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
const ALL_OUTPUT_PATH = "./src/context-providers/events/webhooks.all.json";
const ALL_OBJECTS_PATH = "./src/context-providers/events/objects.all.json";
const DROP_OUTPUT_PATH = "./src/context-providers/events/webhooks.drop.json";
const DROP_OBJECTS_PATH = "./src/context-providers/events/objects.drop.json";
const STRIP_OUTPUT_PATH = "./src/context-providers/events/webhooks.strip.json";
const STRIP_OBJECTS_PATH = "./src/context-providers/events/objects.strip.json";
// Parse --all flag
const generateAll = process.argv.includes("--all");
// Events to drop - not valid workflow triggers (GitHub App or API-only events)
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const DROPPED_EVENTS = new Set([
"branch_protection_configuration",
"code_scanning_alert",
"commit_comment",
"custom_property",
"custom_property_values",
"dependabot_alert",
"deploy_key",
"github_app_authorization",
"installation",
"installation_repositories",
"installation_target",
"marketplace_purchase",
"member",
"membership",
"merge_group",
"meta",
"org_block",
"organization",
"package",
"personal_access_token_request",
"ping",
"repository",
"repository_advisory",
"repository_ruleset",
"secret_scanning_alert",
"secret_scanning_alert_location",
"security_advisory",
"security_and_analysis",
"sponsorship",
"star",
"team",
"team_add"
]);
// Events to keep - valid workflow triggers
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const KEPT_EVENTS = new Set([
"branch_protection_rule",
"check_run",
"check_suite",
"create",
"delete",
"deployment",
"deployment_status",
"discussion",
"discussion_comment",
"fork",
"gollum",
"issue_comment",
"issues",
"label",
"milestone",
"page_build",
"project",
"project_card",
"project_column",
"projects_v2",
"projects_v2_item",
"public",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
"pull_request_review_thread",
"push",
"registry_package",
"release",
"repository_dispatch",
"repository_import",
"repository_vulnerability_alert",
"status",
"watch",
"workflow_dispatch",
"workflow_job",
"workflow_run"
]);
/**
* Fields to strip from the JSON data.
*
* EVENT_ACTION_FIELDS: stripped from each event action object (top level only)
* Example event action object before stripping:
* {
* "description": "This event is triggered when...", // <-- stripped
* "summary": "A brief summary", // <-- stripped
* "availability": ["repository"], // <-- stripped
* "category": "issues", // <-- stripped
* "action": "opened", // kept
* "bodyParameters": [...] // kept
* }
*
* BODY_PARAM_FIELDS: stripped from every bodyParameters object, recursively through childParamsGroups
* Example bodyParameter object before stripping:
* {
* "type": "object", // <-- stripped
* "name": "changes", // kept (used for property names)
* "in": "body", // <-- stripped
* "description": "The changes that were made.", // kept (used for hover docs)
* "isRequired": true, // <-- stripped
* "enum": ["a", "b"], // <-- stripped
* "default": "a", // <-- stripped
* "childParamsGroups": [ // kept (used for nested properties)
* {
* "type": "string", // <-- stripped (recursive)
* "name": "from", // kept
* "isRequired": true // <-- stripped (recursive)
* }
* ]
* }
*/
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category"];
const BODY_PARAM_FIELDS = ["type", "in", "isRequired", "enum", "default"];
/**
* Strip fields from a bodyParameter object and recursively from childParamsGroups.
*/
function stripBodyParam(param: any): any {
if (typeof param !== "object" || param === null) {
return param;
}
const result: any = {};
for (const [key, value] of Object.entries(param)) {
if (BODY_PARAM_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "childParamsGroups" && Array.isArray(value)) {
result[key] = value.map(stripBodyParam);
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from event action data.
*/
function stripEventActionFields(action: any): any {
const result: any = {};
for (const [key, value] of Object.entries(action)) {
if (EVENT_ACTION_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "bodyParameters" && Array.isArray(value)) {
result[key] = value.map((p: any) => (typeof p === "number" ? p : stripBodyParam(p)));
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from all webhooks.
* Structure: { eventName: { actionName: { ...fields } } }
*/
function stripFields(webhooks: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
const result: Record<string, Record<string, any>> = {};
for (const [eventName, actions] of Object.entries(webhooks)) {
result[eventName] = {};
for (const [actionName, actionData] of Object.entries(actions)) {
result[eventName][actionName] = stripEventActionFields(actionData);
}
}
return result;
}
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
if (!rawWebhooks) {
@@ -20,11 +199,51 @@ for (const webhook of Object.values(rawWebhooks)) {
await Promise.all(webhooks.map(webhook => webhook.process()));
// Check for unknown events (not in DROPPED_EVENTS or KEPT_EVENTS)
const unknownEvents: string[] = [];
for (const webhook of webhooks) {
if (!DROPPED_EVENTS.has(webhook.category) && !KEPT_EVENTS.has(webhook.category)) {
if (!unknownEvents.includes(webhook.category)) {
unknownEvents.push(webhook.category);
}
}
}
if (unknownEvents.length > 0) {
console.error("");
console.error("══════════════════════════════════════════════════════════════════");
console.error("ERROR: New webhook event(s) detected!");
console.error("══════════════════════════════════════════════════════════════════");
console.error("");
console.error("The following events are not categorized:");
for (const event of unknownEvents.sort()) {
console.error(` - ${event}`);
}
console.error("");
console.error("Action required:");
console.error(" 1. Check if the event is a valid workflow trigger:");
console.error(
" https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows"
);
console.error("");
console.error(" 2. Add the event to DROPPED_EVENTS or KEPT_EVENTS in:");
console.error(" languageservice/script/webhooks/index.ts");
console.error("");
console.error(" 3. See docs/json-data-files.md for more details.");
console.error("");
process.exit(1);
}
// The category is the name of the webhook
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
for (const webhook of webhooks) {
if (!webhook.action) webhook.action = "default";
// Drop unused events
if (DROPPED_EVENTS.has(webhook.category)) {
continue;
}
if (categorizedWebhooks[webhook.category]) {
categorizedWebhooks[webhook.category][webhook.action] = webhook;
} else {
@@ -33,7 +252,59 @@ for (const webhook of webhooks) {
}
}
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
// Strip fields before deduplication
const strippedWebhooks = stripFields(categorizedWebhooks);
// Deduplicate after dropping and stripping
const objectsArray = deduplicateWebhooks(strippedWebhooks);
// Write optimized output
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, null, 2));
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(strippedWebhooks).length} events)`);
console.log(`Wrote ${OBJECTS_PATH} (${objectsArray.length} objects)`);
// Optionally generate intermediate versions for size comparison
if (generateAll) {
// Helper to deep clone
function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
// Build full webhooks (no drop, no strip) from fresh data
const fullWebhooks: Record<string, Record<string, any>> = {};
for (const webhook of webhooks) {
const w = clone(webhook);
if (!w.action) w.action = "default";
fullWebhooks[w.category] ||= {};
fullWebhooks[w.category][w.action] = w;
}
// Generate all version (no drop, no strip)
const allWebhooks = clone(fullWebhooks);
const allObjects = deduplicateWebhooks(allWebhooks);
await fs.writeFile(ALL_OUTPUT_PATH, JSON.stringify(allWebhooks, null, 2));
await fs.writeFile(ALL_OBJECTS_PATH, JSON.stringify(allObjects, null, 2));
console.log(`Wrote ${ALL_OUTPUT_PATH} (${Object.keys(allWebhooks).length} events)`);
console.log(`Wrote ${ALL_OBJECTS_PATH} (${allObjects.length} objects)`);
// Generate drop-only version (drop events, no strip)
const dropWebhooks = clone(fullWebhooks);
for (const event of DROPPED_EVENTS) {
delete dropWebhooks[event];
}
const dropObjects = deduplicateWebhooks(dropWebhooks);
await fs.writeFile(DROP_OUTPUT_PATH, JSON.stringify(dropWebhooks, null, 2));
await fs.writeFile(DROP_OBJECTS_PATH, JSON.stringify(dropObjects, null, 2));
console.log(`Wrote ${DROP_OUTPUT_PATH} (${Object.keys(dropWebhooks).length} events)`);
console.log(`Wrote ${DROP_OBJECTS_PATH} (${dropObjects.length} objects)`);
// Generate strip-only version (strip fields, no drop)
const stripWebhooks = stripFields(clone(fullWebhooks));
const stripObjects = deduplicateWebhooks(stripWebhooks);
await fs.writeFile(STRIP_OUTPUT_PATH, JSON.stringify(stripWebhooks, null, 2));
await fs.writeFile(STRIP_OBJECTS_PATH, JSON.stringify(stripObjects, null, 2));
console.log(`Wrote ${STRIP_OUTPUT_PATH} (${Object.keys(stripWebhooks).length} events)`);
console.log(`Wrote ${STRIP_OBJECTS_PATH} (${stripObjects.length} objects)`);
}
-54
View File
@@ -1,54 +0,0 @@
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";
@@ -1,67 +0,0 @@
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;
}
@@ -1,4 +0,0 @@
import {CodeActionProvider} from "../types";
import {addMissingInputsProvider} from "./add-missing-inputs";
export const quickfixProviders: CodeActionProvider[] = [addMissingInputsProvider];
@@ -1,90 +0,0 @@
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);
}
});
}
});
@@ -1,227 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import { TextEdit } from "vscode-languageserver-types";
import { TextDocument } from "vscode-languageserver-textdocument";
import { 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
};
}
@@ -1,9 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -1,7 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -1,10 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: ${{ runner.os }}-
path: ""
key: ""
@@ -1,8 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
restore-keys: ${{ runner.os }}-
@@ -1,9 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -1,6 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
-21
View File
@@ -1,21 +0,0 @@
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;
}
@@ -299,7 +299,16 @@ jobs:
"on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual(["arch", "name", "os", "temp", "tool_cache"]);
expect(result.map(x => x.label)).toEqual([
"arch",
"debug",
"environment",
"name",
"os",
"temp",
"tool_cache",
"workspace"
]);
});
describe("job if", () => {
@@ -861,7 +870,7 @@ jobs:
});
describe("strategy context", () => {
it("strategy is not suggested when outside of a matrix job", async () => {
it("strategy is suggested even when no strategy defined", async () => {
const input = `
on: push
@@ -875,7 +884,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).not.toContain("strategy");
expect(result.map(x => x.label)).toContain("strategy");
});
it("strategy is suggested within a matrix job", async () => {
@@ -922,7 +931,7 @@ jobs:
});
describe("matrix context", () => {
it("matrix is not suggested when outside of a matrix job", async () => {
it("matrix is suggested even when no strategy defined", async () => {
const input = `
on: push
@@ -936,7 +945,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).not.toContain("strategy");
expect(result.map(x => x.label)).toContain("matrix");
});
it("matrix is suggested within a matrix job", async () => {
@@ -1123,10 +1132,12 @@ jobs:
"github",
"inputs",
"job",
"matrix",
"needs",
"runner",
"secrets",
"steps",
"strategy",
"vars",
"contains",
"endsWith",
@@ -1268,7 +1279,7 @@ jobs:
on: push
jobs:
a:
uses: ./reusable-workflow-with-outputs.yaml
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
b:
needs: [a]
runs-on: ubuntu-latest
@@ -0,0 +1,169 @@
import {complete} from "./complete";
import {TextDocument} from "vscode-languageserver-textdocument";
import {clearCache} from "./utils/workflow-cache";
import {getPositionFromCursor} from "./test-utils/cursor-position";
beforeEach(() => {
clearCache();
});
describe("Issue #81 - multi-line if expression completion", () => {
it("should complete in block scalar if with | (exact position)", async () => {
// Exact reproduction from issue - cursor after "github." in block scalar
const input = `on: push
jobs:
build:
if: |
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 5 (0-indexed) = " github.", character 13 = after the dot
const pos = {line: 5, character: 13};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
expect(result.map(x => x.label)).toContain("actor");
});
it("should complete in block scalar if with > (exact position)", async () => {
const input = `on: push
jobs:
build:
if: >
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
const pos = {line: 5, character: 13};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete in block scalar with multiple lines", async () => {
const input = `on: push
jobs:
build:
if: |
github.event_name == 'push' &&
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
// Skip 1 to skip the `|` block scalar indicator (same character as cursor marker)
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete step if in block scalar", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo
if: |
github.
`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 7 = " github.", character 15 = after the dot (8 spaces + 7 chars)
const pos = {line: 7, character: 15};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete in block scalar with ${{ expression markers", async () => {
// This case works because transform() skips lines with ${{
// Note: Using explicit position because | appears in multiple places (block scalar, ||, cursor)
const input = `on: push
jobs:
build:
if: |
\${{
github.ref == 'refs/heads/main' ||
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 6 = " github." = 8 spaces + 7 chars = 15 chars, cursor after dot is at char 15
const pos = {line: 6, character: 15};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("ref");
expect(result.map(x => x.label)).toContain("ref_name");
});
});
describe("Edge cases for getOffsetInContent", () => {
it("should complete in single-line if (not block scalar)", async () => {
const input = `on: push
jobs:
build:
if: github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete on third content line of block scalar", async () => {
const input = `on: push
jobs:
build:
if: |
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete when block scalar has empty first line", async () => {
const input = `on: push
jobs:
build:
if: |
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
});
@@ -21,7 +21,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
|
`;
@@ -49,7 +49,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
username: monalisa
|
@@ -74,7 +74,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets:
|
`;
@@ -102,7 +102,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets: |
`;
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
@@ -117,7 +117,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets:
envPAT: "myPAT"
|
+51 -4
View File
@@ -5,6 +5,7 @@ import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
@@ -19,7 +20,6 @@ import {isPotentiallyExpression} from "./utils/expression-detection";
import {findToken} from "./utils/find-token";
import {guessIndentation} from "./utils/indentation-guesser";
import {mapRange} from "./utils/range";
import {getRelCharOffset} from "./utils/rel-char-pos";
import {isPlaceholder, transform} from "./utils/transform";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
import {Value, ValueProviderConfig} from "./value-providers/config";
@@ -238,12 +238,12 @@ function getExpressionCompletionItems(
currentInput = stringToken.source || stringToken.value;
}
const relCharOffset = getRelCharOffset(token.range, currentInput, pos);
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
const cursorOffset = getOffsetInContent(token.range, currentInput, pos);
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
try {
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
mapExpressionCompletionItem(item, currentInput[relCharOffset])
mapExpressionCompletionItem(item, currentInput[cursorOffset])
);
} catch (e) {
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
@@ -274,3 +274,50 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
};
}
/**
* Converts a document position to an offset within the token's content string.
*/
function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: Position): number {
const range = mapRange(tokenRange);
if (range.start.line === range.end.line) {
// Single-line example:
// if: github.ref == 'main'
// ^8 ^15 (cursor)
// currentInput = "github.ref == 'main'"
// offset = 15 - 8 = 7
return pos.character - range.start.character;
}
// Multi-line example:
// if: | <- line 3 (range.start.line)
// first line <- line 4, content line 0
// second line <- line 5, content line 1
// github. <- line 6, content line 2, cursor at index 11
// ^11 (cursor)
//
// currentInput = " first line\n second line\n github."
// ^0 ^15 ^32 ^43
// Line index within content.
// From the example:
// lineIndexWithinContent = pos.line - range.start.line - 1
// = 6 - 3 - 1 = 2
const lineIndexWithinContent = pos.line - range.start.line - 1;
// Length of content before current line.
// From the example:
// lengthOfContentBeforeCurrentLine => 14 + 1 = 15 (after first iteration)
// => 31 + 1 = 32 (after second iteration)
let lengthOfContentBeforeCurrentLine = 0;
for (let i = 0; i < lineIndexWithinContent; i++) {
lengthOfContentBeforeCurrentLine = currentInput.indexOf("\n", lengthOfContentBeforeCurrentLine) + 1;
}
// Final offset within content.
// From the example:
// finalOffset = lengthOfContentBeforeCurrentLine + pos.character
// = 32 + 11 = 43
return lengthOfContentBeforeCurrentLine + pos.character;
}
@@ -0,0 +1,97 @@
import {DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context";
import {getContext, Mode} from "./default";
describe("getContext", () => {
const emptyWorkflowContext: WorkflowContext = {
uri: "test.yaml",
template: undefined
};
describe("when no contextProviderConfig is provided", () => {
it("should mark secrets context as incomplete", async () => {
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext).toBeDefined();
expect(secretsContext.complete).toBe(false);
});
it("should mark vars context as incomplete", async () => {
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext).toBeDefined();
expect(varsContext.complete).toBe(false);
});
it("should not mark other contexts as incomplete", async () => {
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
const envContext = result.get("env") as DescriptionDictionary;
const githubContext = result.get("github") as DescriptionDictionary;
// These contexts are derived from the workflow file, so they can be complete
expect(envContext).toBeDefined();
expect(envContext.complete).toBe(true);
expect(githubContext).toBeDefined();
expect(githubContext.complete).toBe(true);
});
});
describe("when contextProviderConfig returns a value", () => {
it("should use the provided context for secrets", async () => {
const providedContext = new DescriptionDictionary();
providedContext.complete = true; // Provider fetched from API, so it's complete
const config = {
getContext: () => Promise.resolve(providedContext)
};
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets");
expect(secretsContext).toBe(providedContext);
expect((secretsContext as DescriptionDictionary).complete).toBe(true);
});
it("should use the provided context for vars", async () => {
const providedContext = new DescriptionDictionary();
providedContext.complete = true;
const config = {
getContext: () => Promise.resolve(providedContext)
};
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars");
expect(varsContext).toBe(providedContext);
expect((varsContext as DescriptionDictionary).complete).toBe(true);
});
});
describe("when contextProviderConfig returns undefined", () => {
it("should mark secrets as incomplete", async () => {
const config = {
getContext: () => Promise.resolve(undefined)
};
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext.complete).toBe(false);
});
it("should mark vars as incomplete", async () => {
const config = {
getContext: () => Promise.resolve(undefined)
};
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext.complete).toBe(false);
});
});
});
@@ -32,15 +32,24 @@ export async function getContext(
): Promise<DescriptionDictionary> {
const context = new DescriptionDictionary();
const filteredNames = filterContextNames(names, workflowContext);
for (const contextName of filteredNames) {
// All context names are valid - strategy and matrix are always available
// (with default values when no strategy block is defined)
for (const contextName of names) {
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
if (value.kind === Kind.Null) {
context.add(contextName, value);
continue;
}
value = (await config?.getContext(contextName, value, workflowContext, mode)) || value;
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
if (remoteValue) {
value = remoteValue;
} else if (contextName === "secrets" || contextName === "vars") {
// Without a context provider to fetch remote secrets/vars, we can't know
// what values exist, so mark the context as incomplete to avoid false
// "Context access might be invalid" warnings
value.complete = false;
}
context.add(contextName, value, getDescription(RootContext, contextName));
}
@@ -74,11 +83,14 @@ function getDefaultContext(name: string, workflowContext: WorkflowContext, mode:
case "runner":
return objectToDictionary({
os: "Linux",
arch: "X64",
debug: "1",
environment: "github-hosted",
name: "GitHub Actions 2",
os: "Linux",
temp: "/home/runner/work/_temp",
tool_cache: "/opt/hostedtoolcache",
temp: "/home/runner/work/_temp"
workspace: "/home/runner/work/repo"
});
case "secrets":
@@ -103,18 +115,3 @@ function objectToDictionary(object: {[key: string]: string}): DescriptionDiction
return dictionary;
}
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
return contextNames.filter(name => {
switch (name) {
case "matrix":
case "strategy":
return hasStrategy(workflowContext);
}
return true;
});
}
function hasStrategy(workflowContext: WorkflowContext): boolean {
return workflowContext.job?.strategy !== undefined || workflowContext.reusableWorkflowJob?.strategy !== undefined;
}
@@ -239,7 +239,13 @@
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software).\""
},
"debug": {
"description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `1`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
"description": "This is set only if [`ACTIONS_STEP_DEBUG`](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `\"1\"`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
},
"environment": {
"description": "The environment of the runner executing the job. Possible values are `github-hosted` for GitHub-hosted runners, or `self-hosted` for self-hosted runners."
},
"workspace": {
"description": "The runner-specific working directory path for the job."
}
},
"strategy": {
@@ -1,4 +1,4 @@
import descriptions from "./descriptions.json";
import descriptions from "./descriptions.min.json";
export const RootContext = "root";
const FunctionContext = "functions";
@@ -0,0 +1,102 @@
import {DescriptionDictionary} from "@actions/expressions";
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads";
describe("eventPayloads", () => {
describe("getSupportedEventTypes", () => {
it("returns action types for push event", () => {
const types = getSupportedEventTypes("push");
expect(types).toContain("default");
});
it("returns action types for issues event", () => {
const types = getSupportedEventTypes("issues");
expect(types.length).toBeGreaterThan(1);
expect(types).toContain("opened");
expect(types).toContain("closed");
});
});
describe("getEventPayload", () => {
it("returns payload for push event", () => {
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// Verify common fields exist
expect(payload?.get("ref")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
expect(payload?.get("sender")).toBeDefined();
});
it("returns payload for issues event", () => {
const payload = getEventPayload("issues", "opened");
expect(payload).toBeDefined();
expect(payload?.get("action")).toBeDefined();
expect(payload?.get("issue")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
});
it("preserves descriptions for hover documentation", () => {
// This test ensures bodyParameters[].description is not stripped
// during JSON optimization. The description field is used for hover
// documentation in the workflow editor.
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// Get the description for a well-known field
// repository should have a description like "A repository on GitHub"
const repoDescription = payload?.getDescription("repository");
expect(repoDescription).toBeDefined();
expect(repoDescription?.length).toBeGreaterThan(0);
// sender should have a description
const senderDescription = payload?.getDescription("sender");
expect(senderDescription).toBeDefined();
expect(senderDescription?.length).toBeGreaterThan(0);
});
it("preserves childParamsGroups for nested property access", () => {
// This test ensures bodyParameters[].childParamsGroups is not stripped
// during JSON optimization. childParamsGroups defines nested properties
// used for autocompletion like github.event.repository.owner.login
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// repository has nested properties like owner, license, etc.
const repository = payload?.get("repository") as DescriptionDictionary | undefined;
expect(repository).toBeDefined();
// repository.owner should exist (nested via childParamsGroups)
const owner = repository?.get("owner") as DescriptionDictionary | undefined;
expect(owner).toBeDefined();
// repository.owner.login should exist (deeply nested)
const login = owner?.get("login");
expect(login).toBeDefined();
});
it("preserves name fields for property identification", () => {
// This test ensures bodyParameters[].name is not stripped
// during JSON optimization. The name field identifies each property.
const payload = getEventPayload("issues", "opened");
expect(payload).toBeDefined();
// Verify well-known property names exist
expect(payload?.get("action")).toBeDefined();
expect(payload?.get("issue")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
expect(payload?.get("sender")).toBeDefined();
// Verify nested property names work
const issue = payload?.get("issue") as DescriptionDictionary | undefined;
expect(issue?.get("title")).toBeDefined();
expect(issue?.get("number")).toBeDefined();
expect(issue?.get("user")).toBeDefined();
});
it("returns undefined for unknown event", () => {
const payload = getEventPayload("not_a_real_event", "default");
expect(payload).toBeUndefined();
});
});
});
@@ -1,10 +1,10 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import webhookObjects from "./objects.json";
import webhooks from "./webhooks.json";
import webhookObjects from "./objects.min.json";
import webhooks from "./webhooks.min.json";
import schedule from "./schedule.json";
import workflow_call from "./workflow_call.json";
import schedule from "./schedule.min.json";
import workflow_call from "./workflow_call.min.json";
const customEventPayloads: {[name: string]: unknown} = {
schedule,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -64,7 +64,7 @@ describe("matrix context", () => {
expect(workflowContext.job).toBeUndefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new DescriptionDictionary());
expect(context).toEqual(new data.Null());
});
it("strategy not defined", () => {
@@ -73,7 +73,7 @@ describe("matrix context", () => {
expect(workflowContext.job!.strategy).toBeUndefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new DescriptionDictionary());
expect(context).toEqual(new data.Null());
});
it("strategy is not a mapping token", () => {
@@ -81,7 +81,7 @@ describe("matrix context", () => {
expect(workflowContext.job!.strategy).toBeDefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new DescriptionDictionary());
expect(context).toEqual(new data.Null());
});
it("matrix is not defined", () => {
@@ -10,7 +10,8 @@ export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode):
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
if (!strategy || !isMapping(strategy)) {
return new DescriptionDictionary();
// No strategy defined - matrix is null at runtime (not empty object)
return new data.Null();
}
const matrix = strategy.find("matrix");
@@ -111,7 +111,7 @@ jobs:
on: push
jobs:
a:
uses: ./reusable-workflow-with-outputs.yaml
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
b:
needs: [a]
@@ -0,0 +1,78 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context";
import {getStepsContext} from "./steps";
function createWorkflowContext(stepIds: string[], currentStepId?: string): WorkflowContext {
return {
job: {
steps: stepIds.map(id => ({id}))
},
step: currentStepId ? {id: currentStepId} : undefined
} as WorkflowContext;
}
describe("steps context", () => {
it("returns empty dictionary when no job", () => {
const workflowContext = {} as WorkflowContext;
const context = getStepsContext(workflowContext);
expect(context.pairs().length).toBe(0);
});
it("returns empty dictionary when no steps", () => {
const workflowContext = {job: {}} as WorkflowContext;
const context = getStepsContext(workflowContext);
expect(context.pairs().length).toBe(0);
});
it("includes steps with user-defined ids", () => {
const workflowContext = createWorkflowContext(["step-a", "step-b"]);
const context = getStepsContext(workflowContext);
expect(context.get("step-a")).toBeDefined();
expect(context.get("step-b")).toBeDefined();
});
it("excludes generated step ids (starting with __)", () => {
const workflowContext = createWorkflowContext(["step-a", "__generated"]);
const context = getStepsContext(workflowContext);
expect(context.get("step-a")).toBeDefined();
expect(context.get("__generated")).toBeUndefined();
});
it("excludes current step and later steps", () => {
const workflowContext = createWorkflowContext(["step-a", "step-b", "step-c"], "step-b");
const context = getStepsContext(workflowContext);
expect(context.get("step-a")).toBeDefined();
expect(context.get("step-b")).toBeUndefined();
expect(context.get("step-c")).toBeUndefined();
});
describe("step outputs", () => {
it("outputs is a dictionary, not null", () => {
const workflowContext = createWorkflowContext(["step-a"]);
const context = getStepsContext(workflowContext);
const stepContext = context.get("step-a");
expect(stepContext).toBeDefined();
expect(isDescriptionDictionary(stepContext!)).toBe(true);
const outputs = (stepContext as DescriptionDictionary).get("outputs");
expect(outputs).toBeDefined();
expect(isDescriptionDictionary(outputs!)).toBe(true);
});
it("outputs is marked incomplete to allow dynamic outputs", () => {
const workflowContext = createWorkflowContext(["step-a"]);
const context = getStepsContext(workflowContext);
const stepContext = context.get("step-a") as DescriptionDictionary;
const outputs = stepContext.get("outputs") as DescriptionDictionary;
// Outputs should be incomplete since we can't know what outputs a step will produce
expect(outputs.complete).toBe(false);
});
});
});
@@ -31,7 +31,10 @@ function stepContext(): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context
const d = new DescriptionDictionary();
d.add("outputs", new data.Null(), getDescription("steps", "outputs"));
// Step outputs are dynamic - actions can generate outputs based on their inputs
const outputs = new DescriptionDictionary();
outputs.complete = false;
d.add("outputs", outputs, getDescription("steps", "outputs"));
// Can be "success", "failure", "cancelled", or "skipped"
d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));
@@ -0,0 +1,126 @@
import {data} from "@actions/expressions";
import {Job} from "@actions/workflow-parser/model/workflow-template";
import {BooleanToken} from "@actions/workflow-parser/templates/tokens/boolean-token";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {NumberToken} from "@actions/workflow-parser/templates/tokens/number-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context";
import {getStrategyContext} from "./strategy";
function stringToToken(value: string) {
return new StringToken(undefined, undefined, value, undefined);
}
function boolToToken(value: boolean) {
return new BooleanToken(undefined, undefined, value, undefined);
}
function numberToToken(value: number) {
return new NumberToken(undefined, undefined, value, undefined);
}
function contextFromStrategy(strategy?: TemplateToken) {
return {
job: {
strategy: strategy
}
} as WorkflowContext;
}
describe("strategy context", () => {
describe("no strategy defined", () => {
it("returns defaults when job is undefined", () => {
const workflowContext = {} as WorkflowContext;
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
it("returns defaults when strategy is undefined", () => {
const job = {} as Job;
const workflowContext = {job} as WorkflowContext;
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
it("returns defaults when strategy is not a mapping", () => {
const workflowContext = contextFromStrategy(stringToToken("hello"));
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
});
describe("strategy defined with partial properties", () => {
it("uses specified fail-fast, defaults for others", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("fail-fast"), boolToToken(false));
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
it("uses specified max-parallel, defaults for others", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("max-parallel"), numberToToken(5));
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(5));
});
it("only has matrix defined, all strategy properties use defaults", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
const matrix = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), matrix);
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
});
});
describe("strategy with all properties defined", () => {
it("uses all specified values", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("fail-fast"), boolToToken(false));
strategy.add(stringToToken("max-parallel"), numberToToken(3));
const workflowContext = contextFromStrategy(strategy);
const context = getStrategyContext(workflowContext);
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
// job-index and job-total are runtime values, not specified in YAML
expect(context.get("job-index")).toEqual(new data.NumberData(0));
expect(context.get("job-total")).toEqual(new data.NumberData(1));
expect(context.get("max-parallel")).toEqual(new data.NumberData(3));
});
});
});
@@ -3,15 +3,24 @@ import {isMapping, isScalar, isString} from "@actions/workflow-parser";
import {WorkflowContext} from "../context/workflow-context";
import {scalarToData} from "../utils/scalar-to-data";
// Default strategy values when no strategy block is defined
const DEFAULT_STRATEGY = {
"fail-fast": new data.BooleanData(true),
"job-index": new data.NumberData(0),
"job-total": new data.NumberData(1),
"max-parallel": new data.NumberData(1)
};
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
if (!strategy || !isMapping(strategy)) {
// No strategy defined - return defaults that match runtime behavior
return new DescriptionDictionary(
...keys.map(key => {
return {key, value: new data.Null()};
return {key, value: DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]};
})
);
}
@@ -31,7 +40,8 @@ export function getStrategyContext(workflowContext: WorkflowContext): Descriptio
for (const key of keys) {
if (!strategyContext.get(key)) {
strategyContext.add(key, new data.Null());
// Use default value for missing properties
strategyContext.add(key, DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]);
}
}
@@ -14,7 +14,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
us|ername:
`;
@@ -31,7 +31,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs-no-description.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs-no-description.yaml
with:
us|ername:
`;
@@ -48,7 +48,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-outputs.yaml
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
echo_outputs:
runs-on: ubuntu-latest
needs: build
+2 -5
View File
@@ -110,11 +110,8 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
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)"
);
// Cron description is now shown via diagnostics, not hover
expect(result?.contents).toEqual("");
});
it("on a cron mapping key", async () => {
+2 -24
View File
@@ -2,11 +2,9 @@ import {data, DescriptionDictionary, Parser} from "@actions/expressions";
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
import {Lexer} from "@actions/expressions/lexer";
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 {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {isBasicExpression, isString} from "@actions/workflow-parser/templates/tokens/type-guards";
import {isBasicExpression} from "@actions/workflow-parser/templates/tokens/type-guards";
import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
@@ -23,7 +21,7 @@ import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-p
import {HoverVisitor} from "./expression-hover/visitor";
import {info} from "./log";
import {isPotentiallyExpression} from "./utils/expression-detection";
import {findToken, TokenResult} from "./utils/find-token";
import {findToken} from "./utils/find-token";
import {mapRange} from "./utils/range";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
@@ -89,17 +87,6 @@ export async function hover(document: TextDocument, position: Position, config?:
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)) {
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
description = appendContext(description, token.definitionInfo?.allowedContext);
@@ -156,15 +143,6 @@ async function getDescription(
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(
exprPos: ExpressionPos,
context: DescriptionDictionary,
-1
View File
@@ -5,4 +5,3 @@ export {hover} from "./hover";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log";
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
export {getCodeActions} from "./code-actions";
@@ -5,9 +5,9 @@ export const testFileProvider: FileProvider = {
// eslint-disable-next-line @typescript-eslint/require-await
getFileContent: async ref => {
switch (fileIdentifier(ref)) {
case "monalisa/octocat/workflow.yaml@main":
case "monalisa/octocat/.github/workflows/workflow.yaml@main":
return {
name: "monalisa/octocat/workflow.yaml",
name: "monalisa/octocat/.github/workflows/workflow.yaml",
content: `
on: workflow_call
jobs:
@@ -31,9 +31,9 @@ jobs:
`
};
case "./reusable-workflow.yaml":
case "./.github/workflows/reusable-workflow.yaml":
return {
name: "reusable-workflow.yaml",
name: ".github/workflows/reusable-workflow.yaml",
content: `
on: workflow_call
jobs:
@@ -44,9 +44,9 @@ jobs:
`
};
case "./reusable-workflow-with-inputs.yaml":
case "./.github/workflows/reusable-workflow-with-inputs.yaml":
return {
name: "reusable-workflow-with-inputs.yaml",
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
content: `
on:
workflow_call:
@@ -76,9 +76,9 @@ jobs:
`
};
case "./reusable-workflow-with-inputs-no-description.yaml":
case "./.github/workflows/reusable-workflow-with-inputs-no-description.yaml":
return {
name: "reusable-workflow-with-inputs.yaml",
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
content: `
on:
workflow_call:
@@ -95,9 +95,9 @@ jobs:
`
};
case "./reusable-workflow-with-outputs.yaml":
case "./.github/workflows/reusable-workflow-with-outputs.yaml":
return {
name: "reusable-workflow-with-outputs.yaml",
name: ".github/workflows/reusable-workflow-with-outputs.yaml",
content: `
on:
workflow_call:
-15
View File
@@ -1,15 +0,0 @@
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
import {Position} from "vscode-languageserver-textdocument";
import {mapRange} from "./range";
export function getRelCharOffset(tokenRange: TokenRange, currentInput: string, pos: Position): number {
const range = mapRange(tokenRange);
if (range.start.line !== range.end.line) {
const lines = currentInput.split("\n");
const lineDiff = pos.line - range.start.line - 1;
const linesBeforeCusor = lines.slice(0, lineDiff);
return linesBeforeCusor.join("\n").length + pos.character + 1;
} else {
return pos.character - range.start.character;
}
}
+12 -67
View File
@@ -1,30 +1,12 @@
import { isMapping } from "@actions/workflow-parser";
import { isActionStep } from "@actions/workflow-parser/model/type-guards";
import { Step } from "@actions/workflow-parser/model/workflow-template";
import { ScalarToken } from "@actions/workflow-parser/templates/tokens/scalar-token";
import { TemplateToken } from "@actions/workflow-parser/templates/tokens/template-token";
import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver-types";
import { ActionReference, parseActionReference } from "./action";
import { mapRange } from "./utils/range";
import { ValidationConfig } from "./validate";
export const DiagnosticCode = {
MissingRequiredInputs: "missing-required-inputs"
} as const;
export interface MissingInputsDiagnosticData {
action: ActionReference;
missingInputs: Array<{
name: string;
default?: string;
}>;
hasWithKey: boolean;
// Indentation of the `with:` key if present, or the step's base indentation
withIndent?: number;
stepIndent: number;
// Position where new content should be inserted
insertPosition: { line: number; character: number };
}
import {isMapping} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {parseActionReference} from "./action";
import {mapRange} from "./utils/range";
import {ValidationConfig} from "./validate";
export async function validateAction(
diagnostics: Diagnostic[],
@@ -53,7 +35,7 @@ export async function validateAction(
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const { key, value } of stepToken) {
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
@@ -63,7 +45,7 @@ export async function validateAction(
const stepInputs = new Map<string, ScalarToken>();
if (withToken && isMapping(withToken)) {
for (const { key } of withToken) {
for (const {key} of withToken) {
stepInputs.set(key.toString(), key);
}
}
@@ -101,47 +83,10 @@ export async function validateAction(
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
const stepIndent = stepToken.range ? stepToken.range.start.column - 1 : 0; // 0-indexed
const withIndent = withKey?.range ? withKey.range.start.column - 1 : undefined;
// Calculate insert position
// For withToken, we need to handle empty mappings specially - insert after the with: line
let insertPosition: { line: number; character: number };
if (withToken?.range) {
// Check if with: has any children by comparing start and end lines
const hasChildren = stepInputs.size > 0;
if (hasChildren) {
// Insert after the last child
insertPosition = { line: withToken.range.end.line - 1, character: 0 };
} else {
// Empty with: block - insert on the next line after with:
insertPosition = { line: withKey!.range!.end.line, character: 0 };
}
} else if (stepToken.range) {
insertPosition = { line: stepToken.range.end.line - 1, character: 0 };
} else {
insertPosition = { line: 0, character: 0 };
}
const diagnosticData: MissingInputsDiagnosticData = {
action,
missingInputs: missingRequiredInputs.map(([name, input]) => ({
name,
default: input.default
})),
hasWithKey: withKey !== undefined,
withIndent,
stepIndent,
insertPosition
};
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
message: message,
code: DiagnosticCode.MissingRequiredInputs,
data: diagnosticData
message: message
});
}
}
+3 -74
View File
@@ -249,28 +249,7 @@ jobs:
line: 7
}
},
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
}
severity: DiagnosticSeverity.Error
}
]);
});
@@ -315,32 +294,7 @@ jobs:
line: 7
}
},
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
}
severity: DiagnosticSeverity.Error
}
]);
});
@@ -369,32 +323,7 @@ jobs:
line: 6
}
},
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
}
severity: DiagnosticSeverity.Error
}
]);
});
@@ -0,0 +1,245 @@
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 concurrency deadlock", () => {
describe("should error on matching concurrency groups", () => {
it("simple string match", async () => {
const input = `
on: push
concurrency: test
jobs:
job1:
runs-on: ubuntu-latest
concurrency: test
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(2);
// Workflow-level warning
expect(concurrencyErrors[0]).toMatchObject({
message: "Concurrency group 'test' is also used by job 'job1'. This will cause a deadlock.",
severity: DiagnosticSeverity.Error
});
// Job-level warning
expect(concurrencyErrors[1]).toMatchObject({
message: "Concurrency group 'test' is also defined at the workflow level. This will cause a deadlock.",
severity: DiagnosticSeverity.Error
});
});
it("workflow mapping form, job string form", async () => {
const input = `
on: push
concurrency:
group: my-group
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
concurrency: my-group
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(2);
expect(concurrencyErrors[0].message).toContain("my-group");
expect(concurrencyErrors[0].message).toContain("deploy");
});
it("workflow string form, job mapping form", async () => {
const input = `
on: push
concurrency: deploy-group
jobs:
build:
runs-on: ubuntu-latest
concurrency:
group: deploy-group
cancel-in-progress: true
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(2);
expect(concurrencyErrors[0].message).toContain("deploy-group");
});
it("both mapping forms", async () => {
const input = `
on: push
concurrency:
group: shared
jobs:
job1:
runs-on: ubuntu-latest
concurrency:
group: shared
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(2);
});
it("multiple jobs with matching concurrency", async () => {
const input = `
on: push
concurrency: shared
jobs:
job1:
runs-on: ubuntu-latest
concurrency: shared
steps:
- run: echo hi
job2:
runs-on: ubuntu-latest
concurrency: shared
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
// Should have 2 warnings per job (workflow + job) = 4 total, but workflow is only warned once per match
// Actually: 1 workflow warning per matching job + 1 job warning per matching job = 4 total
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(4);
});
});
describe("should not warn", () => {
it("different concurrency groups", async () => {
const input = `
on: push
concurrency: workflow-group
jobs:
job1:
runs-on: ubuntu-latest
concurrency: job-group
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(0);
});
it("workflow concurrency is an expression", async () => {
const input = `
on: push
concurrency: \${{ github.ref }}
jobs:
job1:
runs-on: ubuntu-latest
concurrency: test
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(0);
});
it("job concurrency is an expression", async () => {
const input = `
on: push
concurrency: test
jobs:
job1:
runs-on: ubuntu-latest
concurrency: \${{ github.ref }}
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(0);
});
it("no workflow-level concurrency", async () => {
const input = `
on: push
jobs:
job1:
runs-on: ubuntu-latest
concurrency: test
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(0);
});
it("no job-level concurrency", async () => {
const input = `
on: push
concurrency: test
jobs:
job1:
runs-on: ubuntu-latest
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(0);
});
it("case sensitive - different case is different group", async () => {
const input = `
on: push
concurrency: Test
jobs:
job1:
runs-on: ubuntu-latest
concurrency: test
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(0);
});
it("workflow concurrency group in mapping is an expression", async () => {
const input = `
on: push
concurrency:
group: \${{ github.ref }}
jobs:
job1:
runs-on: ubuntu-latest
concurrency: test
steps:
- run: echo hi`;
const result = await validate(createDocument("wf.yaml", input));
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
expect(concurrencyErrors).toHaveLength(0);
});
});
});
@@ -635,7 +635,7 @@ jobs:
fail-fast: true
matrix:
node: [14, 16]
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
username: User-\${{ strategy.fail-fast }}
`;
@@ -654,7 +654,7 @@ jobs:
strategy:
matrix:
node: [14, 16]
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
username: \${{ matrix.node }}
`;
@@ -681,7 +681,8 @@ jobs:
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toEqual([]);
// Strategy context is always available with default values
expect(result).toEqual([]);
});
it("invalid strategy property", async () => {
@@ -996,22 +997,8 @@ jobs:
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Context access might be invalid: matrix",
range: {
end: {
character: 36,
line: 8
},
start: {
character: 18,
line: 8
}
},
severity: DiagnosticSeverity.Warning
}
]);
// Matrix is null when no strategy is defined, accessing properties on null is valid
expect(result).toEqual([]);
});
it("basic matrix", async () => {
@@ -1609,6 +1596,48 @@ jobs:
expect(result).toEqual([]);
});
it("allows runner.environment context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: runner.environment == 'github-hosted'
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows runner.debug context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: runner.debug == '1'
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows runner.workspace context", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: runner.workspace != ''
run: echo hello`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("allows env context", async () => {
const input = `
on: push
@@ -0,0 +1,152 @@
/**
* Test validation behavior when no context providers are configured.
*
* When contextProviderConfig is not provided (or returns incomplete data),
* we should skip validation for secrets/vars rather than showing false
* positive "Context access might be invalid" warnings.
*
* This is important for offline/disconnected scenarios where API calls
* to fetch secrets/vars are not possible.
*/
import {validate} from "./validate";
import {createDocument} from "./test-utils/document";
import {clearCache} from "./utils/workflow-cache";
beforeEach(() => {
clearCache();
});
describe("validation without context providers", () => {
describe("secrets context", () => {
it("should not warn on secrets.GITHUB_TOKEN", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "test"
env:
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
it("should not warn on custom secrets when no provider configured", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "test"
env:
API_KEY: \${{ secrets.MY_API_KEY }}
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
it("should not warn on secrets with environment", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: echo "test"
env:
API_KEY: \${{ secrets.API_KEY }}
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
});
describe("vars context", () => {
it("should not warn on vars when no provider configured", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "\${{ vars.ENVIRONMENT }}"
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
it("should not warn on vars with environment", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: echo "\${{ vars.API_URL }}"
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
it("should not warn on vars with fallback pattern", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "\${{ vars.OPTIONAL_VAR || 'default-value' }}"
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
});
describe("combined secrets and vars", () => {
it("should not warn on workflow using both secrets and vars", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: |
echo "Deploying to \${{ vars.API_URL }}"
echo "Using region \${{ vars.AWS_REGION }}"
env:
API_KEY: \${{ secrets.API_KEY }}
AWS_SECRET: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
});
});
+118 -1
View File
@@ -181,7 +181,7 @@ jobs:
expect(result.length).toBe(1);
expect(result[0]).toEqual({
message: "Invalid cron string",
message: "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
range: {
end: {
character: 21,
@@ -195,6 +195,96 @@ jobs:
} 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:
'Actions schedules run at most every 5 minutes. "*/1 * * * *" (runs every minute) will not run as frequently as specified.',
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("Actions schedules run at most every 5 minutes.");
});
it("invalid YAML", async () => {
// This YAML has some mismatched single-quotes, which causes the string to be terminated early
// within the fromJSON() expression.
@@ -295,4 +385,31 @@ jobs:
expect(result).toEqual([]);
});
});
describe("workflow_dispatch", () => {
it("allows empty string in choice options", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on:
workflow_dispatch:
inputs:
plugin-name:
description: Specific plugin to build
type: choice
options:
- ''
- foo
- bar
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo`
)
);
expect(result).toEqual([]);
});
});
});
+450 -1
View File
@@ -1,7 +1,8 @@
import {Lexer, Parser, data} from "@actions/expressions";
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
@@ -27,6 +28,9 @@ import {validateAction} from "./validate-action";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
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 = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
@@ -143,11 +147,34 @@ 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) {
const context = getProviderContext(documentUri, template, root, token.range);
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
// a value provider is defined for a token and if it is, validate the values match.
if (token.range && validationDefinition) {
@@ -182,6 +209,9 @@ async function additionalValidations(
}
}
}
// Validate concurrency deadlock between workflow and job levels
validateConcurrencyDeadlock(diagnostics, template);
}
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
@@ -198,6 +228,357 @@ 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: `Actions schedules run at most every 5 minutes. "${cronValue}" (${description.toLowerCase()}) will not run as frequently as specified.`,
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(
documentUri: URI,
template: WorkflowTemplate,
@@ -334,3 +715,71 @@ async function validateExpression(
);
}
}
/**
* Validates that workflow-level and job-level concurrency groups don't match,
* which would cause a deadlock at runtime.
*/
function validateConcurrencyDeadlock(diagnostics: Diagnostic[], template: WorkflowTemplate): void {
const workflowGroup = getStaticConcurrencyGroup(template.concurrency);
if (!workflowGroup) {
return; // No workflow-level concurrency or it's an expression
}
for (const job of template.jobs || []) {
if (!job.concurrency) {
continue;
}
const jobGroup = getStaticConcurrencyGroup(job.concurrency);
if (!jobGroup) {
continue; // Job concurrency is an expression
}
if (workflowGroup.value === jobGroup.value) {
// Error on workflow-level concurrency
if (template.concurrency.range) {
diagnostics.push({
message: `Concurrency group '${workflowGroup.value}' is also used by job '${job.id.value}'. This will cause a deadlock.`,
range: mapRange(template.concurrency.range),
severity: DiagnosticSeverity.Error
});
}
// Error on job-level concurrency
if (job.concurrency.range) {
diagnostics.push({
message: `Concurrency group '${jobGroup.value}' is also defined at the workflow level. This will cause a deadlock.`,
range: mapRange(job.concurrency.range),
severity: DiagnosticSeverity.Error
});
}
}
}
}
/**
* Extracts the static concurrency group name from a concurrency token.
* Returns undefined if the token is an expression or doesn't have a static group.
*/
function getStaticConcurrencyGroup(token: TemplateToken | undefined): StringToken | undefined {
if (!token || token.isExpression) {
return undefined;
}
// Simple string form: concurrency: "test"
if (isString(token)) {
return token;
}
// Mapping form: concurrency: { group: "test", cancel-in-progress: true }
if (isMapping(token)) {
for (const pair of token) {
if (isString(pair.key) && pair.key.value === "group" && isString(pair.value) && !pair.value.isExpression) {
return pair.value;
}
}
}
return undefined;
}
@@ -0,0 +1,894 @@
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/test.yml@v1..v2
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1..v2': 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:
build:
uses: monalisa/octocat/workflow.yaml@not-a-branch
uses: monalisa/octocat/.github/workflows/workflow.yaml@not-a-branch
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
@@ -58,7 +58,7 @@ jobs:
line: 5
},
end: {
character: 53,
character: 71,
line: 5
}
}
@@ -72,7 +72,7 @@ on: push
jobs:
build:
uses: monalisa/octocat/workflow.yaml@main
uses: monalisa/octocat/.github/workflows/workflow.yaml@main
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
@@ -87,7 +87,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow.yaml
uses: ./.github/workflows/reusable-workflow.yaml
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
@@ -102,7 +102,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets:
envPAT: pat
`;
@@ -119,7 +119,7 @@ jobs:
line: 5
},
end: {
character: 46,
character: 64,
line: 5
}
}
@@ -133,7 +133,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
username: monalisa
secrets:
@@ -0,0 +1,202 @@
import {validate} from "./validate";
import {createDocument} from "./test-utils/document";
import {clearCache} from "./utils/workflow-cache";
beforeEach(() => {
clearCache();
});
describe("YAML anchors and aliases", () => {
it("should handle anchors and aliases in env", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
job1:
runs-on: ubuntu-latest
env: &env
ENV1: env1
ENV2: env2
steps:
- run: exit 0
job2:
runs-on: ubuntu-latest
env: *env
steps:
- run: exit 0
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
it("should handle multiple aliases to the same anchor", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
env: &shared
SHARED: true
jobs:
job1:
runs-on: ubuntu-latest
env: *shared
steps:
- run: exit 0
job2:
runs-on: ubuntu-latest
env: *shared
steps:
- run: exit 0
job3:
runs-on: ubuntu-latest
env: *shared
steps:
- run: exit 0
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
it("should handle anchors in matrix strategy", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
include: &matrix-include
- os: ubuntu-latest
node: 18
- os: windows-latest
node: 20
steps:
- run: exit 0
test2:
runs-on: ubuntu-latest
strategy:
matrix:
include: *matrix-include
steps:
- run: exit 0
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
it("should handle anchors in steps", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- &checkout
uses: actions/checkout@v4
- run: npm test
deploy:
runs-on: ubuntu-latest
steps:
- *checkout
- run: npm run deploy
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
it("should handle scalar anchors", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
build:
runs-on: &runner ubuntu-latest
steps:
- run: exit 0
test:
runs-on: *runner
steps:
- run: exit 0
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
it("should work without anchors (control test)", async () => {
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
job1:
runs-on: ubuntu-latest
env:
ENV1: env1
ENV2: env2
steps:
- run: exit 0
job2:
runs-on: ubuntu-latest
env:
ENV1: env1
ENV2: env2
steps:
- run: exit 0
`
);
const result = await validate(doc);
expect(result).toEqual([]);
});
it("should handle circular aliases without hanging", async () => {
// This is an invalid use case (alias referencing parent) but should not hang
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
build:
runs-on: ubuntu-latest
env: &myenv
FOO: bar
nested: *myenv
steps:
- run: exit 0
`
);
// Should complete without hanging - circular portion is silently ignored
// which may cause downstream validation errors, but that's acceptable
const result = await validate(doc);
expect(result).toBeDefined();
});
it("should handle undefined alias references", async () => {
// Reference to non-existent anchor - yaml library should report error
const doc = createDocument(
"wf.yaml",
`
on: push
jobs:
build:
runs-on: ubuntu-latest
env: *nonexistent
steps:
- run: exit 0
`
);
const result = await validate(doc);
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.20"
"version": "0.3.25"
}
+125 -671
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
{
"name": "actions-languageservices",
"private": true,
"type": "module",
"workspaces": [
"./expressions",
"./workflow-parser",
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
/**
* Minifies JSON files by removing whitespace.
*
* Usage: node script/minify-json.js <file1.json> <file2.json> ...
*
* For each input file, creates a corresponding .min.json file.
* Example: src/data.json -> src/data.min.json
*/
import {promises as fs} from "fs";
import path from "path";
const files = process.argv.slice(2);
if (files.length === 0) {
console.error("Usage: node script/minify-json.js <file1.json> <file2.json> ...");
process.exit(1);
}
for (const file of files) {
try {
const content = await fs.readFile(file, "utf8");
const data = JSON.parse(content);
const minified = JSON.stringify(data);
// Replace .json with .min.json
const ext = path.extname(file);
const outputFile = file.slice(0, -ext.length) + ".min" + ext;
await fs.writeFile(outputFile, minified);
const originalSize = Buffer.byteLength(content, "utf8");
const minifiedSize = Buffer.byteLength(minified, "utf8");
const savings = ((1 - minifiedSize / originalSize) * 100).toFixed(1);
console.log(`${file} -> ${outputFile} (${savings}% smaller)`);
} catch (err) {
console.error(`Error processing ${file}:`, err);
process.exit(1);
}
}
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.20",
"version": "0.3.25",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -38,19 +38,22 @@
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"minify-json": "node ../script/minify-json.js src/workflow-v1.0.json",
"prebuild": "npm run minify-json",
"prepublishOnly": "npm run build && npm run test",
"pretest": "npm run minify-json",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-xlang": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathPattern xlang",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.20",
"@actions/expressions": "^0.3.25",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
"engines": {
"node": ">= 16.15"
"node": ">= 18"
},
"files": [
"dist/**/*"
@@ -1,4 +1,4 @@
import {isValidCron, getCronDescription} from "./cron";
import {isValidCron, getCronDescription, hasCronIntervalLessThan5Minutes} from "./cron";
describe("cron", () => {
describe("valid cron", () => {
@@ -66,14 +66,54 @@ describe("cron", () => {
describe("getCronDescription", () => {
it(`Produces a sentence for valid cron`, () => {
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)"
);
expect(getCronDescription("0 * * * *")).toEqual("Runs every hour");
});
it(`Returns nothing for invalid cron`, () => {
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);
});
});
});
+73 -5
View File
@@ -8,6 +8,78 @@ type Range = {
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 {
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
@@ -46,11 +118,7 @@ export function getCronDescription(cronspec: string): string | undefined {
}
// Make first character lowercase
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;
return "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
}
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`);
// Validate the cron string
if (!isValidCron(cron.value)) {
context.error(cron, "Invalid cron string");
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
}
result.push({cron: cron.value});
} else {
+183
View File
@@ -0,0 +1,183 @@
import * as fs from "fs";
/**
* This test ensures that activity types in workflow-v1.0.json stay in sync with
* the webhooks.json file from the languageservice package.
*
* When this test fails, it means new activity types were added to webhooks.json
* that need to be handled. See docs/json-data-files.md for detailed instructions.
*
* Quick reference for fixing failures:
* 1. Check https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
* Find the event and look at its "Activity types" table to see if the type is a valid workflow trigger.
* 2. If the activity type IS a valid workflow trigger:
* → Add it to the corresponding *-activity-type definition in workflow-v1.0.json
* 3. If the activity type is webhook-only (not in workflow docs):
* → Add it to the WEBHOOK_ONLY list below
* 4. If there's a naming difference between webhook and schema:
* → Add it to the NAME_MAPPINGS list below
* 5. If the schema has a type not in webhooks.json:
* → Add it to the SCHEMA_ONLY list below
*/
describe("schema-sync", () => {
// Activity types that exist in webhooks.json but are intentionally NOT
// supported as workflow triggers. These will be ignored when checking
// webhooks → schema direction.
const WEBHOOK_ONLY: Record<string, string[]> = {
// check_suite: requested and rerequested are webhook-only, not valid workflow triggers
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#check_suite
check_suite: ["requested", "rerequested"],
// registry_package: "default" is a webhook concept, not a workflow trigger type
registry_package: ["default"]
};
// Activity types that exist in workflow schema but are intentionally NOT
// in webhooks.json (schema-only types). These will be ignored when checking
// schema → webhooks direction.
const SCHEMA_ONLY: Record<string, string[]> = {
// registry_package: "updated" is a valid workflow trigger per GitHub docs
// but doesn't exist in webhooks.json (webhooks only has "published" and "default")
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#registry_package
registry_package: ["updated"]
};
// Known naming differences between webhooks.json and workflow-v1.0.json.
// Key: event name, Value: { webhook: "webhookName", schema: "schemaName" }
// These are treated as equivalent when comparing in both directions.
const NAME_MAPPINGS: Record<string, Array<{webhook: string; schema: string}>> = {
// project_column: webhooks.json uses "edited" but workflow triggers use "updated"
// This is a known naming difference - they represent the same action
project_column: [{webhook: "edited", schema: "updated"}]
};
it("activity types in workflow-v1.0.json match webhooks.json", () => {
// Load webhooks.json (relative path from the test runner CWD which is the package root)
const webhooksPath = "../languageservice/src/context-providers/events/webhooks.json";
const webhooks = JSON.parse(fs.readFileSync(webhooksPath, "utf-8")) as Record<string, Record<string, unknown>>;
// Load workflow-v1.0.json
const schemaPath = "./src/workflow-v1.0.json";
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as {
definitions: Record<string, {"allowed-values"?: string[]; description?: string}>;
};
const mismatches: string[] = [];
// Build mapping helpers for each event
const getWebhookToSchemaMapping = (eventName: string): Map<string, string> => {
const map = new Map<string, string>();
for (const mapping of NAME_MAPPINGS[eventName] || []) {
map.set(mapping.webhook, mapping.schema);
}
return map;
};
const getSchemaToWebhookMapping = (eventName: string): Map<string, string> => {
const map = new Map<string, string>();
for (const mapping of NAME_MAPPINGS[eventName] || []) {
map.set(mapping.schema, mapping.webhook);
}
return map;
};
// Check both directions for each event
for (const [eventName, eventData] of Object.entries(webhooks)) {
const webhookTypes = Object.keys(eventData);
if (webhookTypes.length === 0) continue;
const schemaTypeName = `${eventName.replace(/_/g, "-")}-activity-type`;
const schemaDef = schema.definitions[schemaTypeName];
// If there's no activity type definition in the schema, this event
// doesn't support activity types in workflows (e.g., push, pull)
if (!schemaDef || !schemaDef["allowed-values"]) continue;
const schemaTypes = new Set(schemaDef["allowed-values"]);
const webhookOnly = new Set(WEBHOOK_ONLY[eventName] || []);
const schemaOnly = new Set(SCHEMA_ONLY[eventName] || []);
const webhookToSchema = getWebhookToSchemaMapping(eventName);
const schemaToWebhook = getSchemaToWebhookMapping(eventName);
// Direction 1: webhooks → schema
// Check that each webhook type exists in schema (or has a mapping, or is webhook-only)
for (const webhookType of webhookTypes) {
if (webhookOnly.has(webhookType)) continue;
const mappedSchemaType = webhookToSchema.get(webhookType);
if (mappedSchemaType) {
// Has a mapping - check the mapped name exists in schema
if (!schemaTypes.has(mappedSchemaType)) {
mismatches.push(
`Event "${eventName}": webhook type "${webhookType}" maps to "${mappedSchemaType}" but "${mappedSchemaType}" not found in schema`
);
}
} else {
// No mapping - check the type exists directly
if (!schemaTypes.has(webhookType)) {
mismatches.push(
`Event "${eventName}": missing activity type "${webhookType}" in workflow-v1.0.json (exists in webhooks.json)`
);
}
}
}
// Direction 2: schema → webhooks
// Check that each schema type exists in webhooks (or has a mapping, or is schema-only)
const webhookTypesSet = new Set(webhookTypes);
for (const schemaType of schemaTypes) {
if (schemaOnly.has(schemaType)) continue;
const mappedWebhookType = schemaToWebhook.get(schemaType);
if (mappedWebhookType) {
// Has a mapping - check the mapped name exists in webhooks
if (!webhookTypesSet.has(mappedWebhookType)) {
mismatches.push(
`Event "${eventName}": schema type "${schemaType}" maps to "${mappedWebhookType}" but "${mappedWebhookType}" not found in webhooks.json`
);
}
} else {
// No mapping - check the type exists directly
if (!webhookTypesSet.has(schemaType)) {
mismatches.push(
`Event "${eventName}": extra activity type "${schemaType}" in workflow-v1.0.json (not in webhooks.json)`
);
}
}
}
// Check that the description mentions all allowed values
const activityDefName = `${eventName.replace(/_/g, "-")}-activity`;
const activityDef = schema.definitions[activityDefName];
if (activityDef?.description) {
for (const schemaType of schemaTypes) {
if (!activityDef.description.includes(`\`${schemaType}\``)) {
mismatches.push(
`Event "${eventName}": description in "${activityDefName}" is missing activity type \`${schemaType}\``
);
}
}
}
}
if (mismatches.length > 0) {
const errorMessage = [
"Activity type mismatches found between webhooks.json and workflow-v1.0.json:",
"",
...mismatches,
"",
"To fix these mismatches:",
"1. Check GitHub docs: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows",
"2. Verify the activity type is valid for workflow triggers",
"3. Update the *-activity-type definition in workflow-parser/src/workflow-v1.0.json",
"4. Update the description to list all supported activity types",
"5. If there's a naming difference, add it to NAME_MAPPINGS in schema-sync.test.ts",
"6. If the type is webhook-only, add it to WEBHOOK_ONLY",
"7. If the type is schema-only, add it to SCHEMA_ONLY"
].join("\n");
throw new Error(errorMessage);
}
});
});
+18 -5
View File
@@ -856,7 +856,7 @@
}
},
"pull-request-activity": {
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `ready_for_review`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `locked`, `unlocked`, `enqueued`, `dequeued`, `milestoned`, `demilestoned`, `ready_for_review`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
"one-of": [
"pull-request-activity-type",
"pull-request-activity-types"
@@ -879,9 +879,13 @@
"reopened",
"synchronize",
"converted_to_draft",
"ready_for_review",
"locked",
"unlocked",
"enqueued",
"dequeued",
"milestoned",
"demilestoned",
"ready_for_review",
"review_requested",
"review_request_removed",
"auto_merge_enabled",
@@ -1004,7 +1008,7 @@
}
},
"pull-request-target-activity": {
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `ready_for_review`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `locked`, `unlocked`, `enqueued`, `dequeued`, `milestoned`, `demilestoned`, `ready_for_review`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
"one-of": [
"pull-request-target-activity-type",
"pull-request-target-activity-types"
@@ -1027,9 +1031,13 @@
"reopened",
"synchronize",
"converted_to_draft",
"ready_for_review",
"locked",
"unlocked",
"enqueued",
"dequeued",
"milestoned",
"demilestoned",
"ready_for_review",
"review_requested",
"review_request_removed",
"auto_merge_enabled",
@@ -1539,7 +1547,7 @@
},
"default": "workflow-dispatch-input-default",
"options": {
"type": "sequence-of-non-empty-string",
"type": "sequence-of-string",
"description": "The options of the dropdown list, if the type is a choice."
}
}
@@ -2419,6 +2427,11 @@
"item-type": "non-empty-string"
}
},
"sequence-of-string": {
"sequence": {
"item-type": "string"
}
},
"boolean-needs-context": {
"context": [
"github",
@@ -1,6 +1,6 @@
import {JSONObjectReader} from "../templates/json-object-reader";
import {TemplateSchema} from "../templates/schema";
import WorkflowSchema from "../workflow-v1.0.json";
import WorkflowSchema from "../workflow-v1.0.min.json";
let schema: TemplateSchema;
@@ -1,4 +1,16 @@
import {isCollection, isDocument, isMap, isPair, isScalar, isSeq, LineCounter, parseDocument, Scalar} from "yaml";
import {
isAlias,
isCollection,
isDocument,
isMap,
isPair,
isScalar,
isSeq,
LineCounter,
parseDocument,
Scalar
} from "yaml";
import type {Document} from "yaml";
import type {LinePos} from "yaml/dist/errors";
import type {NodeBase} from "yaml/dist/nodes/Node";
import {ObjectReader} from "../templates/object-reader";
@@ -22,30 +34,31 @@ export type YamlError = {
export class YamlObjectReader implements ObjectReader {
private readonly _generator: Generator<ParseEvent>;
private _current!: IteratorResult<ParseEvent>;
private readonly doc: Document;
private fileId?: number;
private lineCounter = new LineCounter();
public errors: YamlError[] = [];
constructor(fileId: number | undefined, content: string) {
const doc = parseDocument(content, {
this.doc = parseDocument(content, {
lineCounter: this.lineCounter,
keepSourceTokens: true,
uniqueKeys: false // Uniqueness is validated by the template reader
});
for (const err of doc.errors) {
for (const err of this.doc.errors) {
this.errors.push({message: err.message, range: rangeFromLinePos(err.linePos)});
}
this._generator = this.getNodes(doc);
this._generator = this.getNodes(this.doc, new Set());
this.fileId = fileId;
}
private *getNodes(node: unknown): Generator<ParseEvent, void> {
private *getNodes(node: unknown, aliasResolutionStack: Set<unknown>): Generator<ParseEvent, void> {
let range = this.getRange(node as NodeBase | undefined);
if (isDocument(node)) {
yield new ParseEvent(EventType.DocumentStart);
for (const item of this.getNodes(node.contents)) {
for (const item of this.getNodes(node.contents, new Set())) {
yield item;
}
yield new ParseEvent(EventType.DocumentEnd);
@@ -59,7 +72,7 @@ export class YamlObjectReader implements ObjectReader {
}
for (const item of node.items) {
for (const child of this.getNodes(item)) {
for (const child of this.getNodes(item, aliasResolutionStack)) {
yield child;
}
}
@@ -74,12 +87,32 @@ export class YamlObjectReader implements ObjectReader {
yield new ParseEvent(EventType.Literal, YamlObjectReader.getLiteralToken(this.fileId, range, node));
}
// Handle YAML aliases - resolve to the anchored value
if (isAlias(node)) {
const resolved = node.resolve(this.doc);
if (resolved) {
// Prevent infinite recursion from circular aliases
if (aliasResolutionStack.has(resolved)) {
// Silently ignore circular reference - the missing content will cause
// downstream validation errors which is acceptable for this edge case
return;
}
// Track this node in the alias resolution stack
const newStack = new Set(aliasResolutionStack);
newStack.add(resolved);
// Yield the resolved node's contents
yield* this.getNodes(resolved, newStack);
}
// If unresolved, the yaml library already reports an error
return;
}
if (isPair(node)) {
const scalarKey = node.key as Scalar;
range = this.getRange(scalarKey);
const key = scalarKey.value as string;
yield new ParseEvent(EventType.Literal, new StringToken(this.fileId, range, key, undefined));
for (const child of this.getNodes(node.value)) {
for (const child of this.getNodes(node.value, aliasResolutionStack)) {
yield child;
}
}
+16
View File
@@ -120,6 +120,8 @@ on:
- unassigned
- labeled
- unlabeled
- milestoned
- demilestoned
- opened
- edited
- closed
@@ -129,6 +131,8 @@ on:
- ready_for_review
- locked
- unlocked
- enqueued
- dequeued
- review_requested
- review_request_removed
- auto_merge_enabled
@@ -160,6 +164,8 @@ on:
- unassigned
- labeled
- unlabeled
- milestoned
- demilestoned
- opened
- edited
- closed
@@ -169,6 +175,8 @@ on:
- ready_for_review
- locked
- unlocked
- enqueued
- dequeued
- review_requested
- review_request_removed
- auto_merge_enabled
@@ -386,6 +394,8 @@ jobs:
"unassigned",
"labeled",
"unlabeled",
"milestoned",
"demilestoned",
"opened",
"edited",
"closed",
@@ -395,6 +405,8 @@ jobs:
"ready_for_review",
"locked",
"unlocked",
"enqueued",
"dequeued",
"review_requested",
"review_request_removed",
"auto_merge_enabled",
@@ -441,6 +453,8 @@ jobs:
"unassigned",
"labeled",
"unlabeled",
"milestoned",
"demilestoned",
"opened",
"edited",
"closed",
@@ -450,6 +464,8 @@ jobs:
"ready_for_review",
"locked",
"unlocked",
"enqueued",
"dequeued",
"review_requested",
"review_request_removed",
"auto_merge_enabled",