Compare commits

...
Author SHA1 Message Date
eric sciple c04c1b26f4 Optimize webhooks JSON with compact format and string interning
- Convert params to compact array format (type-based dispatch)
- Intern duplicate property names into string table
- Use negative indices for object references to distinguish from string indices
- Rename objects.json → webhooks.objects.json, add webhooks.strings.json
- Move event filters to JSON for maintainability
- Add CI validation job to verify optimization correctness

Reduces combined minified size by ~67% (453 KB → 148 KB), ~27% gzipped (23 KB → 17 KB).
2025-12-06 20:05:44 +00: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]andGitHub Actions 7b9adb106e Release extension version 0.3.23 (#230)
Co-authored-by: GitHub Actions <[email protected]>
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]andGitHub Actions 22c36bc946 Release extension version 0.3.22 (#228)
Co-authored-by: GitHub Actions <[email protected]>
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]andGitHub Actions dfb411f71e Release extension version 0.3.21 (#226)
Co-authored-by: GitHub Actions <[email protected]>
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
40 changed files with 27107 additions and 150670 deletions
+70 -3
View File
@@ -12,18 +12,85 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 16.15 - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 16.15 node-version: ${{ matrix.node-version }}
cache: 'npm' cache: 'npm'
registry-url: 'https://npm.pkg.github.com' registry-url: 'https://npm.pkg.github.com'
- run: npm ci - run: npm ci --engine-strict
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm run format-check -ws - run: npm run format-check -ws
- run: npm run build -ws - run: npm run build -ws
- run: npm run lint -ws - run: npm run lint -ws
- run: npm test -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
validate-webhooks:
runs-on: ubuntu-latest
name: Validate webhook optimization
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: Build workspaces
run: npm run build -ws
- name: Generate full webhooks file
run: cd languageservice && npm run update-webhooks
- name: Run optimization validation tests
run: cd languageservice && npm test -- --testPathPattern=eventPayloads
- name: Verify validation tests ran
run: |
if [ ! -f languageservice/src/context-providers/events/webhooks.full.validation-complete ]; then
echo "ERROR: Validation tests did not run!"
echo "The webhooks.full.validation-complete marker file was not created."
exit 1
fi
echo "Validation tests completed at: $(cat languageservice/src/context-providers/events/webhooks.full.validation-complete)"
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 16.x node-version: 22.x
cache: "npm" cache: "npm"
scope: '@actions' scope: '@actions'
+10 -1
View File
@@ -2,4 +2,13 @@
*/dist */dist
lerna-debug.log lerna-debug.log
node_modules node_modules
.DS_Store .DS_Store
# Minified JSON (generated at build time)
*.min.json
# Full webhooks source (generated by update-webhooks, used for validation tests)
*.full.json
# Validation marker (generated by tests)
*.validation-complete
+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 - [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 - [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 ### Note
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions. Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
+223
View File
@@ -0,0 +1,223 @@
# 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, shared objects are deduplicated, property names are interned
2. **Compacted using a space-efficient format** — params use type-based dispatch arrays instead of objects
3. **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/webhooks.objects.json` | Deduplicated shared object definitions referenced by webhooks |
| `src/context-providers/events/webhooks.strings.json` | Interned property names shared by webhooks and objects |
| `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`, `webhooks.objects.json`, and `webhooks.strings.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. **Compacts** params into a space-efficient array format, keeping only `name`, `description`, and `childParamsGroups` (see [Compact Format](#compact-format))
5. **Deduplicates** shared object definitions into `webhooks.objects.json`
6. **Interns** duplicate property names into `webhooks.strings.json` (see [String Interning](#string-interning))
7. 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/src/context-providers/events/event-filters.json`:
- Add to `kept` array if it's a valid workflow trigger
- Add to `dropped` array 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"
import objects from "./events/webhooks.objects.min.json"
import strings from "./events/webhooks.strings.min.json"
```
## CI Verification
CI verifies that generated source files are up-to-date:
1. Runs `npm run update-webhooks` to regenerate webhooks.json, webhooks.objects.json, and webhooks.strings.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` array in `src/context-providers/events/event-filters.json` for the full list.
## Compact Format
Params are converted from verbose objects into compact arrays, keeping only the fields needed for autocompletion and hover docs (`name`, `description`, `childParamsGroups`). Unused fields like `type`, `in`, `isRequired`, `enum`, and `default` are discarded.
| Format | Meaning |
|--------|---------|
| `"name"` | Name only (no description, no children) |
| `[name, desc]` | Name + description (arr[1] is a string) |
| `[name, children]` | Name + children (arr[1] is an array) |
| `[name, desc, children]` | Name + description + children |
The reader uses `typeof arr[1]` to determine the format: if it's a string, it's a description; if it's an array, it's children.
**Example:**
```json
// Before (object format)
{
"name": "issue",
"description": "The issue itself.",
"childParamsGroups": [
{ "name": "id" },
{ "name": "title", "description": "Issue title" }
]
}
// After (compact format)
["issue", "The issue itself.", [
"id",
["title", "Issue title"]
]]
```
## String Interning
Property names that appear 2+ times are "interned" into a shared string table (`webhooks.strings.json`). In the compact arrays, these names are replaced with non-negative numeric indices:
```json
// webhooks.strings.json
["url", "id", "name", ...] // Index 0 = "url", 1 = "id", 2 = "name"
// webhooks.json - uses indices instead of strings
{
"push": {
"default": {
"p": [
[0, "The URL..."], // 0 = "url" from string table
[1, "Unique ID"], // 1 = "id"
2 // 2 = "name" (name-only, no description)
]
}
}
}
```
**How to distinguish indices from other values:**
- **Negative numbers** → Object indices: `-1` = object 0, `-2` = object 1, etc. (formula: `-(index + 1)`)
- **Non-negative numbers** → String indices (references into `webhooks.strings.json`)
- **Literal strings** → Singletons (names appearing only once, not interned)
Singletons are kept as literal strings for readability and to avoid the overhead of adding rarely-used names to the string table.
## Deduplication
Shared object definitions are extracted into `webhooks.objects.json` and referenced by negative index:
```json
// webhooks.objects.json
[
["url", "The URL"], // Index 0 (referenced as -1)
["id", "Unique identifier"], // Index 1 (referenced as -2)
[...]
]
// webhooks.json - negative numbers reference objects
{
"push": {
"default": {
"p": [-1, -2, ["ref", "The git ref"]] // -1 = object 0, -2 = object 1
}
}
}
```
This reduces duplication when the same object structure appears in multiple events (e.g., `repository`, `sender`, `organization`).
## Size Reduction
The optimizations achieve approximately 99% file size reduction:
| Stage | Minified | Gzip |
|-------|----------|------|
| Original (webhooks.full.json) | 15.8 MB | 968 KB |
| After optimization (combined) | 152 KB | 15.6 KB |
| **Reduction** | **99%** | **98%** |
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/expressions", "name": "@actions/expressions",
"version": "0.3.20", "version": "0.3.23",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"source": "./src/index.ts", "source": "./src/index.ts",
@@ -44,7 +44,7 @@
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"engines": { "engines": {
"node": ">= 16.15" "node": ">= 18"
}, },
"files": [ "files": [
"dist/**/*" "dist/**/*"
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/languageserver", "name": "@actions/languageserver",
"version": "0.3.20", "version": "0.3.23",
"description": "Language server for GitHub Actions", "description": "Language server for GitHub Actions",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -43,8 +43,8 @@
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"dependencies": { "dependencies": {
"@actions/languageservice": "^0.3.20", "@actions/languageservice": "^0.3.23",
"@actions/workflow-parser": "^0.3.20", "@actions/workflow-parser": "^0.3.23",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0", "@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2", "vscode-languageserver": "^8.0.2",
@@ -52,7 +52,7 @@
"yaml": "^2.1.3" "yaml": "^2.1.3"
}, },
"engines": { "engines": {
"node": ">= 16.15" "node": ">= 18"
}, },
"files": [ "files": [
"dist/**/*" "dist/**/*"
+8 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/languageservice", "name": "@actions/languageservice",
"version": "0.3.20", "version": "0.3.23",
"description": "Language service for GitHub Actions", "description": "Language service for GitHub Actions",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -37,22 +37,25 @@
"format-check": "prettier --check '**/*.ts'", "format-check": "prettier --check '**/*.ts'",
"lint": "eslint 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix '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/webhooks.objects.json src/context-providers/events/webhooks.strings.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", "prepublishOnly": "npm run build && npm run test",
"pretest": "npm run minify-json",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest", "test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch", "test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"update-webhooks": "ts-node-esm script/webhooks/index.ts", "update-webhooks": "npx tsx script/webhooks/update-webhooks.ts",
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"dependencies": { "dependencies": {
"@actions/expressions": "^0.3.20", "@actions/expressions": "^0.3.23",
"@actions/workflow-parser": "^0.3.20", "@actions/workflow-parser": "^0.3.23",
"vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2", "vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8", "vscode-uri": "^3.0.8",
"yaml": "^2.1.1" "yaml": "^2.1.1"
}, },
"engines": { "engines": {
"node": ">= 16.15" "node": ">= 18"
}, },
"files": [ "files": [
"dist/**/*" "dist/**/*"
+46 -10
View File
@@ -1,5 +1,38 @@
import Webhook from "./webhook"; import Webhook from "./webhook";
/**
* Get the name from a param.
* Formats: "name" (string), or [name, ...] (array)
*/
function getParamName(param: any): string {
if (typeof param === "string") {
return param;
}
if (Array.isArray(param)) {
return param[0];
}
return param.name;
}
/**
* Get params from a webhook action.
* Uses 'p' (short key) if present, falls back to 'bodyParameters'
*/
function getParams(webhook: any): any[] {
return webhook.p || webhook.bodyParameters || [];
}
/**
* Set params on a webhook action using the short key 'p'
*/
function setParams(webhook: any, params: any[]): void {
if (webhook.p !== undefined) {
webhook.p = params;
} else {
webhook.bodyParameters = params;
}
}
// Store any repeated body parameters in an array // Store any repeated body parameters in an array
// and replace them in the webhook with an index in the array // and replace them in the webhook with an index in the array
export function deduplicateWebhooks(webhooks: Record<string, Record<string, Webhook>>): any[] { export function deduplicateWebhooks(webhooks: Record<string, Record<string, Webhook>>): any[] {
@@ -10,10 +43,11 @@ export function deduplicateWebhooks(webhooks: Record<string, Record<string, Webh
const objectCount: Record<string, number> = {}; const objectCount: Record<string, number> = {};
for (const webhook of iterateWebhooks(webhooks)) { for (const webhook of iterateWebhooks(webhooks)) {
for (const param of webhook.bodyParameters) { for (const param of getParams(webhook)) {
objectsByName[param.name] ||= []; const name = getParamName(param);
const index = findOrAdd(param, objectsByName[param.name]); objectsByName[name] ||= [];
const key = `${param.name}:${index}`; const index = findOrAdd(param, objectsByName[name]);
const key = `${name}:${index}`;
objectCount[key] ||= 0; objectCount[key] ||= 0;
objectCount[key]++; objectCount[key]++;
} }
@@ -27,18 +61,19 @@ export function deduplicateWebhooks(webhooks: Record<string, Record<string, Webh
for (const webhook of iterateWebhooks(webhooks)) { for (const webhook of iterateWebhooks(webhooks)) {
const newParams: any[] = []; const newParams: any[] = [];
for (const param of webhook.bodyParameters) { for (const param of getParams(webhook)) {
const index = find(param, objectsByName[param.name]); const name = getParamName(param);
const key = `${param.name}:${index}`; const index = find(param, objectsByName[name]);
const key = `${name}:${index}`;
if (objectCount[key] > 1) { if (objectCount[key] > 1) {
newParams.push(indexForParam(param, index, bodyParamIndexMap, duplicatedBodyParams)); newParams.push(indexForParam(param, name, index, bodyParamIndexMap, duplicatedBodyParams));
} else { } else {
// If an object is only used once, keep it inline // If an object is only used once, keep it inline
newParams.push(param); newParams.push(param);
} }
} }
webhook.bodyParameters = newParams; setParams(webhook, newParams);
} }
return duplicatedBodyParams; return duplicatedBodyParams;
@@ -74,11 +109,12 @@ function find(param: any, objects: any[]): number {
function indexForParam( function indexForParam(
param: any, param: any,
paramName: string,
paramNameIndex: number, paramNameIndex: number,
objectIndexMap: Record<string, number>, objectIndexMap: Record<string, number>,
duplicatedBodyParams: any[] duplicatedBodyParams: any[]
): number { ): number {
const key = `${param.name}:${paramNameIndex}`; const key = `${paramName}:${paramNameIndex}`;
const existingIndex = objectIndexMap[key]; const existingIndex = objectIndexMap[key];
if (existingIndex !== undefined) { if (existingIndex !== undefined) {
-39
View File
@@ -1,39 +0,0 @@
import {promises as fs} from "fs";
import Webhook from "./webhook.js";
import schemaImport from "rest-api-description/descriptions/api.github.com/dereferenced/api.github.com.deref.json";
import {deduplicateWebhooks} from "./deduplicate.js";
const schema = schemaImport as any;
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
if (!rawWebhooks) {
throw new Error("No webhooks found in schema");
}
const webhooks: Webhook[] = [];
for (const webhook of Object.values(rawWebhooks)) {
webhooks.push(new Webhook(webhook.post));
}
await Promise.all(webhooks.map(webhook => webhook.process()));
// 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";
if (categorizedWebhooks[webhook.category]) {
categorizedWebhooks[webhook.category][webhook.action] = webhook;
} else {
categorizedWebhooks[webhook.category] = {};
categorizedWebhooks[webhook.category][webhook.action] = webhook;
}
}
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
+291
View File
@@ -0,0 +1,291 @@
import {promises as fs} from "fs";
import Webhook from "./webhook.js";
import schemaImport from "rest-api-description/descriptions/api.github.com/dereferenced/api.github.com.deref.json";
import {deduplicateWebhooks} from "./deduplicate.js";
import eventFilters from "../../src/context-providers/events/event-filters.json";
const schema = schemaImport as any;
const DROPPED_EVENTS = new Set(eventFilters.dropped);
const KEPT_EVENTS = new Set(eventFilters.kept);
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
const OBJECTS_PATH = "./src/context-providers/events/webhooks.objects.json";
const STRINGS_PATH = "./src/context-providers/events/webhooks.strings.json";
const FULL_OUTPUT_PATH = "./src/context-providers/events/webhooks.full.json";
/**
* Fields discarded from each event action object (top level only).
* Body parameters are compacted to only keep name, description, and childParamsGroups.
*/
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category", "action"];
/**
* Convert a bodyParameter object to compact array format.
*
* Format (type-based dispatch):
* - "name" - name only (just a string)
* - [name, desc] - name + description (desc is string)
* - [name, [...children]] - name + children (arr[1] is array)
* - [name, desc, [...children]] - name + description + children
*
* The reader uses typeof to determine the meaning:
* - string -> name only
* - array with string arr[1] -> name + description
* - array with array arr[1] -> name + children
*/
function compactParam(param: any): any {
if (typeof param !== "object" || param === null) {
return param;
}
const name: string = param.name;
const desc: string | undefined = param.description;
const children: any[] | undefined = param.childParamsGroups;
const hasDesc = desc && desc.length > 0;
const hasChildren = children && children.length > 0;
if (hasDesc && hasChildren) {
return [name, desc, children.map(compactParam)];
} else if (hasChildren) {
return [name, children.map(compactParam)];
} else if (hasDesc) {
return [name, desc];
} else {
return name; // Just the string, not wrapped in array
}
}
/**
* Convert event action data to compact format.
*/
function compactEventAction(action: any): any {
const result: any = {};
for (const [key, value] of Object.entries(action)) {
if (EVENT_ACTION_FIELDS.includes(key)) {
continue; // Discard this field
}
if (key === "bodyParameters" && Array.isArray(value)) {
// Use short key 'p' for params
result["p"] = value.map((p: any) => (typeof p === "number" ? p : compactParam(p)));
} else {
result[key] = value;
}
}
return result;
}
/**
* Convert all webhooks to compact format.
* Structure: { eventName: { actionName: { ...fields } } }
*/
function compactWebhooks(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] = compactEventAction(actionData);
}
}
return result;
}
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
if (!rawWebhooks) {
throw new Error("No webhooks found in schema");
}
const webhooks: Webhook[] = [];
for (const webhook of Object.values(rawWebhooks)) {
webhooks.push(new Webhook(webhook.post));
}
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' or 'kept' array in:");
console.error(" languageservice/src/context-providers/events/event-filters.json");
console.error("");
console.error(" 3. See docs/json-data-files.md for more details.");
console.error("");
process.exit(1);
}
// Build full webhooks (all events, no transformations) for validation tests
const fullWebhooks: Record<string, Record<string, Webhook>> = {};
for (const webhook of webhooks) {
if (!webhook.action) webhook.action = "default";
if (fullWebhooks[webhook.category]) {
fullWebhooks[webhook.category][webhook.action] = webhook;
} else {
fullWebhooks[webhook.category] = {};
fullWebhooks[webhook.category][webhook.action] = webhook;
}
}
// Write full version (before any optimizations)
await fs.writeFile(FULL_OUTPUT_PATH, JSON.stringify(fullWebhooks, null, 2));
console.log(`Wrote ${FULL_OUTPUT_PATH} (${Object.keys(fullWebhooks).length} events, unoptimized)`);
// 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 {
categorizedWebhooks[webhook.category] = {};
categorizedWebhooks[webhook.category][webhook.action] = webhook;
}
}
// Convert to compact format before deduplication
const compactedWebhooks = compactWebhooks(categorizedWebhooks);
// Deduplicate after compacting
const objectsArray = deduplicateWebhooks(compactedWebhooks);
// ============================================================================
// String Interning (Phase 3)
// ============================================================================
// Intern duplicate property names to reduce file size.
// Names appearing 2+ times are stored in a string table and referenced by index.
// Singleton names stay as literal strings for readability.
/**
* Collect all property names from params (for frequency counting)
*/
function collectNames(param: any, counts: Map<string, number>): void {
if (typeof param === "number") return;
if (typeof param === "string") {
counts.set(param, (counts.get(param) || 0) + 1);
return;
}
if (Array.isArray(param)) {
const name = param[0] as string;
counts.set(name, (counts.get(name) || 0) + 1);
const children = Array.isArray(param[1]) ? param[1] : param[2];
if (children) children.forEach((c: any) => collectNames(c, counts));
}
}
/**
* Replace duplicate names with indices into the string table.
* Object references use negative indices: objectIndex -> -(objectIndex + 1)
* String references use non-negative indices: stringIndex -> stringIndex
*
* @param param - The param to process
* @param nameToIndex - Map from name to string table index
*/
function internNames(param: any, nameToIndex: Map<string, number>): any {
// Object reference (already a number from deduplication) -> make negative
if (typeof param === "number") return -(param + 1);
// String -> intern if in table, otherwise keep as literal
if (typeof param === "string") {
const idx = nameToIndex.get(param);
return idx !== undefined ? idx : param;
}
if (Array.isArray(param)) {
const name = param[0] as string;
const idx = nameToIndex.get(name);
const internedName = idx !== undefined ? idx : name;
// Handle different array formats
if (typeof param[1] === "string" && !Array.isArray(param[1])) {
// [name, desc] or [name, desc, children]
if (param.length === 2) {
return [internedName, param[1]];
} else {
return [internedName, param[1], (param[2] as any[]).map((c: any) => internNames(c, nameToIndex))];
}
} else if (Array.isArray(param[1])) {
// [name, children]
return [internedName, param[1].map((c: any) => internNames(c, nameToIndex))];
}
// Shouldn't happen, but fallback
return [internedName, ...param.slice(1)];
}
return param;
}
// Pass 1: Count all names
const nameCounts = new Map<string, number>();
objectsArray.forEach((obj: any) => collectNames(obj, nameCounts));
for (const event of Object.values(compactedWebhooks)) {
for (const action of Object.values(event as Record<string, any>)) {
if (action.p) action.p.forEach((p: any) => collectNames(p, nameCounts));
}
}
// Build string table from duplicates, sorted by frequency (most common first = smaller indices)
const sortedNames = [...nameCounts.entries()].filter(([, count]) => count >= 2).sort((a, b) => b[1] - a[1]);
const stringTable = sortedNames.map(([name]) => name);
const nameToIndex = new Map(stringTable.map((name, i) => [name, i]));
console.log(
`String table: ${stringTable.length} interned names (${nameCounts.size - stringTable.length} singletons kept inline)`
);
// Pass 2: Intern names in objects and webhooks
// Objects use negative indices, strings use non-negative indices
const internedObjects = objectsArray.map((obj: any) => internNames(obj, nameToIndex));
const internedWebhooks: Record<string, Record<string, any>> = {};
for (const [eventName, actions] of Object.entries(compactedWebhooks)) {
internedWebhooks[eventName] = {};
for (const [actionName, actionData] of Object.entries(actions as Record<string, any>)) {
internedWebhooks[eventName][actionName] = {
p: actionData.p.map((p: any) => internNames(p, nameToIndex))
};
}
}
// Write optimized output with separate string table
// Format: webhooks.strings.json has string table, webhooks.json/webhooks.objects.json reference by index
const finalOutput = {
"//": "Generated file - refer to docs/json-data-files.md for format documentation",
...internedWebhooks
};
await fs.writeFile(STRINGS_PATH, JSON.stringify(stringTable, null, 2));
await fs.writeFile(OBJECTS_PATH, JSON.stringify(internedObjects, null, 2));
await fs.writeFile(OUTPUT_PATH, JSON.stringify(finalOutput, null, 2));
console.log(`Wrote ${STRINGS_PATH} (${stringTable.length} interned strings)`);
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(compactedWebhooks).length} events)`);
console.log(`Wrote ${OBJECTS_PATH} (${internedObjects.length} objects)`);
@@ -1268,7 +1268,7 @@ jobs:
on: push on: push
jobs: jobs:
a: a:
uses: ./reusable-workflow-with-outputs.yaml uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
b: b:
needs: [a] needs: [a]
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -21,7 +21,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow-with-inputs.yaml uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with: with:
| |
`; `;
@@ -49,7 +49,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow-with-inputs.yaml uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with: with:
username: monalisa username: monalisa
| |
@@ -74,7 +74,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow-with-inputs.yaml uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets: secrets:
| |
`; `;
@@ -102,7 +102,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow-with-inputs.yaml uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets: | secrets: |
`; `;
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider}); const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
@@ -117,7 +117,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow-with-inputs.yaml uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets: secrets:
envPAT: "myPAT" envPAT: "myPAT"
| |
@@ -1,4 +1,4 @@
import descriptions from "./descriptions.json"; import descriptions from "./descriptions.min.json";
export const RootContext = "root"; export const RootContext = "root";
const FunctionContext = "functions"; const FunctionContext = "functions";
@@ -0,0 +1,75 @@
{
"dropped": [
"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"
],
"kept": [
"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"
]
}
@@ -0,0 +1,277 @@
import {existsSync} from "fs";
import {fileURLToPath} from "url";
import {dirname, join} from "path";
import {data, DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "@actions/expressions";
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads";
import eventFilters from "./event-filters.json";
const DROPPED_EVENTS = new Set(eventFilters.dropped);
// Check if full webhooks file exists (generated by npm run update-webhooks)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const fullWebhooksPath = join(__dirname, "webhooks.full.json");
const hasFullWebhooks = existsSync(fullWebhooksPath);
type Param = {
name: string;
description?: string;
childParamsGroups?: Param[];
};
type FullAction = {
action?: string;
bodyParameters?: Param[];
};
type FullWebhooks = {
[event: string]: {
[action: string]: FullAction;
};
};
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();
});
});
});
// Optimization validation tests - only run if webhooks.full.json exists
// This file is generated by: npm run update-webhooks
// In CI, a separate job runs these tests after generating the file
const describeIfFullExists = hasFullWebhooks ? describe : describe.skip;
// Marker file path - written after validation tests complete
const fullValidationMarkerPath = join(__dirname, "webhooks.full.validation-complete");
describeIfFullExists("optimization validation", () => {
let keptWebhooks: FullWebhooks;
beforeAll(async () => {
// Dynamically import the full webhooks file using fs to avoid TS module resolution
const {readFileSync} = await import("fs");
const fullWebhooks = JSON.parse(readFileSync(fullWebhooksPath, "utf-8")) as FullWebhooks;
// Filter to only kept events
keptWebhooks = {};
for (const [event, actions] of Object.entries(fullWebhooks)) {
if (!DROPPED_EVENTS.has(event)) {
keptWebhooks[event] = actions;
}
}
});
afterAll(async () => {
// Write marker file to prove validation tests ran
const {writeFileSync} = await import("fs");
writeFileSync(fullValidationMarkerPath, new Date().toISOString());
});
/**
* Build a DescriptionDictionary from raw params (same logic as eventPayloads.ts)
*/
function buildFromParams(params: Param[]): DescriptionDictionary {
const d = new DescriptionDictionary();
for (const param of params) {
if (param.childParamsGroups && param.childParamsGroups.length > 0) {
const child = buildFromParams(param.childParamsGroups);
d.add(param.name, child, param.description);
} else {
// Match the behavior in eventPayloads.ts - don't overwrite existing
if (!d.get(param.name)) {
d.add(param.name, new data.Null(), param.description);
}
}
}
return d;
}
/**
* Compare two DescriptionDictionary structures recursively
*/
function compareStructures(full: DescriptionDictionary, optimized: DescriptionDictionary, path: string): string[] {
const errors: string[] = [];
const fullPairs = full.pairs();
const optimizedPairs = optimized.pairs();
const fullKeys = new Set(fullPairs.map((p: DescriptionPair) => p.key));
const optimizedKeys = new Set(optimizedPairs.map((p: DescriptionPair) => p.key));
// Check for missing keys in optimized
for (const key of fullKeys) {
if (!optimizedKeys.has(key)) {
errors.push(`Missing key in optimized: ${path}.${key}`);
}
}
// Check for extra keys in optimized
for (const key of optimizedKeys) {
if (!fullKeys.has(key)) {
errors.push(`Extra key in optimized: ${path}.${key}`);
}
}
// Compare descriptions and recurse into nested structures
for (const fullPair of fullPairs) {
const optimizedValue = optimized.get(fullPair.key);
if (optimizedValue === undefined) continue;
// Compare descriptions
const fullDesc = full.getDescription(fullPair.key) ?? "";
const optimizedDesc = optimized.getDescription(fullPair.key) ?? "";
if (fullDesc !== optimizedDesc) {
errors.push(
`Description mismatch at ${path}.${fullPair.key}: ` + `full="${fullDesc}" vs optimized="${optimizedDesc}"`
);
}
// Recurse into nested dictionaries
if (isDescriptionDictionary(fullPair.value) && isDescriptionDictionary(optimizedValue)) {
errors.push(...compareStructures(fullPair.value, optimizedValue, `${path}.${fullPair.key}`));
}
}
return errors;
}
it("optimized webhooks match full source for all events and actions", () => {
const allErrors: string[] = [];
for (const [event, actions] of Object.entries(keptWebhooks)) {
for (const [action, actionData] of Object.entries(actions)) {
// Build from full source (use bodyParameters, may be undefined)
const params = actionData.bodyParameters || [];
const fullPayload = buildFromParams(params);
// Get from optimized (deduplicated) source
const optimizedPayload = getEventPayload(event, action);
if (!optimizedPayload) {
allErrors.push(`Missing optimized payload for ${event}.${action}`);
continue;
}
const errors = compareStructures(fullPayload, optimizedPayload, `${event}.${action}`);
allErrors.push(...errors);
}
}
if (allErrors.length > 0) {
fail(
`Optimization validation failed:\n${allErrors.slice(0, 20).join("\n")}` +
(allErrors.length > 20 ? `\n... and ${allErrors.length - 20} more errors` : "")
);
}
});
it("all full source events are present in optimized version", () => {
for (const event of Object.keys(keptWebhooks)) {
const types = getSupportedEventTypes(event);
expect(types.length).toBeGreaterThan(0);
}
});
it("all full source actions are present in optimized version", () => {
for (const [event, actions] of Object.entries(keptWebhooks)) {
for (const action of Object.keys(actions)) {
const payload = getEventPayload(event, action);
expect(payload).toBeDefined();
}
}
});
});
@@ -1,10 +1,11 @@
import {data, DescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary} from "@actions/expressions";
import webhookObjects from "./objects.json"; import webhooksData from "./webhooks.min.json";
import webhooks from "./webhooks.json"; import objectsData from "./webhooks.objects.min.json";
import stringsData from "./webhooks.strings.min.json";
import schedule from "./schedule.json"; import schedule from "./schedule.min.json";
import workflow_call from "./workflow_call.json"; import workflow_call from "./workflow_call.min.json";
const customEventPayloads: {[name: string]: unknown} = { const customEventPayloads: {[name: string]: unknown} = {
schedule, schedule,
@@ -49,9 +50,22 @@ type Param = {
}; };
/** /**
* A full {@link Param} or an index into the objects array for deduplicated parameters * Compact format for params (written by update-webhooks.ts).
*
* Names can be interned (number = index into string table) or literal strings.
* Type-based dispatch:
* - number - interned name only (index into string table)
* - "name" - literal name only (singleton, not interned)
* - [name, desc] - name + description (name is number or string, desc is string)
* - [name, [...children]] - name + children (arr[1] is array)
* - [name, desc, [...children]] - name + description + children
*/ */
type DeduplicatedParam = Param | number; type InternedName = number | string;
type CompactParam =
| InternedName
| [InternedName, string]
| [InternedName, CompactParam[]]
| [InternedName, string, CompactParam[]];
type WebhookPayload = { type WebhookPayload = {
descriptionHtml: string; descriptionHtml: string;
@@ -65,17 +79,33 @@ type Webhooks = {
}; };
}; };
type DeduplicatedWebhooks = { /**
[name: string]: { * Webhooks data format after optimization:
[action: string]: WebhookPayload & { * {
bodyParameters: DeduplicatedParam[]; * [event]: { [action]: { p: CompactParam[] } }
}; * }
}; *
* String table and objects are loaded from separate files.
*/
type WebhooksData = {
[key: string]: {[action: string]: {p: CompactParam[]}};
}; };
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */
const dedupedWebhookPayloads: DeduplicatedWebhooks = webhooks as any; const webhooksJson: WebhooksData = webhooksData as any;
const objects: Param[] = webhookObjects as any; const objectsJson: CompactParam[] = objectsData as any;
// String table and objects are in separate files
const stringTable: string[] = stringsData;
const objects: CompactParam[] = objectsJson;
// Build event payloads map (skip "//" comment key)
const dedupedWebhookPayloads: {[event: string]: {[action: string]: {p: CompactParam[]}}} = {};
for (const [key, value] of Object.entries(webhooksJson)) {
if (key !== "//" && typeof value === "object" && value !== null) {
dedupedWebhookPayloads[key] = value as {[action: string]: {p: CompactParam[]}};
}
}
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */
// Hydrated webhook payloads // Hydrated webhook payloads
@@ -169,10 +199,14 @@ function getWebhookPayload(event: string, action: string): WebhookPayload | unde
return undefined; return undefined;
} }
// Get params from 'p' (compact format)
const dedupedParams = deduplicatedPayload.p || [];
// Recreate the full payload and store it for reuse // Recreate the full payload and store it for reuse
const params = deduplicatedPayload.bodyParameters.map(p => fullParam(p)); const params = dedupedParams.map(p => fullParam(p));
const payload = { const payload = {
...deduplicatedPayload, descriptionHtml: "",
summaryHtml: "",
bodyParameters: params bodyParameters: params
}; };
webhookPayloads[event] ||= {}; webhookPayloads[event] ||= {};
@@ -180,13 +214,65 @@ function getWebhookPayload(event: string, action: string): WebhookPayload | unde
return payload; return payload;
} }
function fullParam(dedupedParam: DeduplicatedParam): Param { /**
if (typeof dedupedParam === "number") { * Resolve an interned name (non-negative number -> string table lookup) or return literal string
if (dedupedParam >= objects.length) { */
throw new Error(`Unknown object ${dedupedParam}`); function resolveName(name: InternedName): string {
if (typeof name === "number") {
if (name < 0 || name >= stringTable.length) {
throw new Error(`Unknown interned name index ${name}`);
} }
return objects[dedupedParam]; return stringTable[name];
}
return name;
}
/**
* Convert a deduplicated param to a full Param.
*
* Compact format (type-based dispatch):
* - negative number - object index: -(n + 1) -> objects[-n - 1]
* - non-negative number - interned string index -> stringTable[n]
* - "name" - literal name only (singleton, not interned)
* - [name, desc] - name + description (name can be number or string)
* - [name, [...children]] - name + children (arr[1] is array)
* - [name, desc, [...children]] - name + description + children
*/
function fullParam(dedupedParam: CompactParam): Param {
// Negative number -> object index
if (typeof dedupedParam === "number" && dedupedParam < 0) {
const objectIndex = -(dedupedParam + 1);
if (objectIndex >= objects.length) {
throw new Error(`Unknown object index ${objectIndex} (from ${dedupedParam})`);
}
return fullParam(objects[objectIndex]);
} }
return dedupedParam; // Non-negative number or literal string -> name only
if (typeof dedupedParam === "number" || typeof dedupedParam === "string") {
return {
name: resolveName(dedupedParam),
description: ""
} as Param;
}
// Compact array format -> convert to Param object
if (Array.isArray(dedupedParam)) {
const arr = dedupedParam;
const name = resolveName(arr[0]);
// Type-based dispatch: if arr[1] is string -> description, if array -> children
const description = typeof arr[1] === "string" ? arr[1] : "";
// arr[1] is children if it's an array, otherwise arr[2] is children (if it exists and is an array)
const childrenArr = Array.isArray(arr[1]) ? arr[1] : Array.isArray(arr[2]) ? arr[2] : undefined;
const childParamsGroups = childrenArr ? childrenArr.map(c => fullParam(c)) : undefined;
return {
name,
description,
childParamsGroups
} as Param;
}
throw new Error(`Unexpected param format: ${JSON.stringify(dedupedParam)}`);
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,416 @@
[
"url",
"id",
"node_id",
"html_url",
"name",
"events_url",
"avatar_url",
"login",
"repos_url",
"type",
"gravatar_id",
"followers_url",
"following_url",
"gists_url",
"starred_url",
"subscriptions_url",
"organizations_url",
"received_events_url",
"site_admin",
"email",
"deleted",
"created_at",
"description",
"updated_at",
"href",
"labels_url",
"comments_url",
"number",
"user",
"statuses_url",
"owner",
"commits_url",
"state",
"open_issues",
"sha",
"title",
"ref",
"body",
"hooks_url",
"issues_url",
"full_name",
"private",
"fork",
"archive_url",
"assignees_url",
"blobs_url",
"branches_url",
"collaborators_url",
"compare_url",
"contents_url",
"contributors_url",
"deployments_url",
"downloads_url",
"forks_url",
"git_commits_url",
"git_refs_url",
"git_tags_url",
"issue_comment_url",
"issue_events_url",
"keys_url",
"languages_url",
"merges_url",
"milestones_url",
"notifications_url",
"pulls_url",
"releases_url",
"stargazers_url",
"subscribers_url",
"subscription_url",
"tags_url",
"teams_url",
"trees_url",
"slug",
"size",
"repo",
"closed_at",
"permissions",
"archived",
"comments",
"homepage",
"license",
"git_url",
"ssh_url",
"clone_url",
"mirror_url",
"svn_url",
"language",
"forks_count",
"stargazers_count",
"watchers_count",
"default_branch",
"open_issues_count",
"is_template",
"topics",
"has_issues",
"has_projects",
"has_wiki",
"has_pages",
"has_downloads",
"disabled",
"visibility",
"pushed_at",
"forks",
"delete_branch_on_merge",
"allow_forking",
"watchers",
"allow_rebase_merge",
"allow_squash_merge",
"allow_auto_merge",
"allow_update_branch",
"allow_merge_commit",
"from",
"web_commit_signoff_required",
"author_association",
"label",
"labels",
"key",
"admin",
"pull",
"triage",
"push",
"maintain",
"draft",
"public",
"commits",
"organization",
"spdx_id",
"master_branch",
"active_lock_reason",
"locked",
"review_comments",
"use_squash_pr_title_as_default",
"squash_merge_commit_title",
"squash_merge_commit_message",
"merge_commit_title",
"merge_commit_message",
"role_name",
"creator",
"stargazers",
"milestone",
"members_url",
"statuses",
"assignee",
"assignees",
"permission",
"privacy",
"repositories_url",
"base",
"status",
"head",
"reactions",
"+1",
"-1",
"confused",
"eyes",
"heart",
"hooray",
"laugh",
"rocket",
"total_count",
"diff_url",
"merged_at",
"patch_url",
"changes",
"due_on",
"repository_url",
"color",
"issue",
"closed_issues",
"pull_request",
"conclusion",
"default",
"_links",
"html",
"author",
"self",
"pull_requests",
"issue_url",
"has_discussions",
"metadata",
"date",
"review_comment",
"auto_merge",
"merge_commit_sha",
"requested_reviewers",
"requested_teams",
"review_comment_url",
"review_comments_url",
"starred_at",
"external_url",
"issues",
"checks",
"contents",
"deployments",
"events",
"head_sha",
"username",
"performed_via_github_app",
"timeline_url",
"additions",
"changed_files",
"deletions",
"maintainer_can_modify",
"mergeable",
"mergeable_state",
"merged",
"merged_by",
"rebaseable",
"head_branch",
"completed_at",
"started_at",
"parent",
"commit_message",
"commit_title",
"enabled_by",
"merge_method",
"pages",
"path",
"actions",
"administration",
"content_references",
"discussions",
"emails",
"environments",
"keys",
"members",
"organization_administration",
"organization_hooks",
"organization_packages",
"organization_plan",
"organization_projects",
"organization_secrets",
"organization_self_hosted_runners",
"organization_user_blocking",
"packages",
"repository_hooks",
"repository_projects",
"secret_scanning_alerts",
"secrets",
"security_events",
"security_scanning_alert",
"single_file",
"team_discussions",
"vulnerability_alerts",
"workflows",
"run_attempt",
"committer",
"message",
"prerelease",
"tag_name",
"target_commitish",
"repository",
"repository_id",
"head_commit",
"timestamp",
"tree_id",
"to",
"content_type",
"published_at",
"category",
"emoji",
"is_answerable",
"state_reason",
"affected_package_name",
"affected_range",
"external_identifier",
"external_reference",
"fixed_in",
"ghsa_id",
"severity",
"check_run_url",
"run_id",
"run_url",
"runner_group_id",
"runner_group_name",
"runner_id",
"runner_name",
"workflow_name",
"steps",
"installations_count",
"client_id",
"client_secret",
"webhook_secret",
"pem",
"after",
"before",
"answer_chosen_at",
"answer_chosen_by",
"answer_html_url",
"release",
"assets",
"assets_url",
"tarball_url",
"upload_url",
"zipball_url",
"app",
"environment",
"summary",
"actor",
"artifacts_url",
"cancel_url",
"check_suite_id",
"check_suite_node_id",
"check_suite_url",
"event",
"head_repository",
"jobs_url",
"logs_url",
"previous_attempt_url",
"referenced_workflows",
"rerun_url",
"run_number",
"run_started_at",
"triggering_actor",
"workflow_id",
"workflow_url",
"note",
"after_id",
"column_id",
"project_url",
"dismiss_reason",
"dismissed_at",
"dismisser",
"fix_reason",
"fixed_at",
"commit",
"workflow_job",
"action",
"temp_clone_token",
"subscribers_count",
"network_count",
"check_suite",
"deployment",
"task",
"original_environment",
"transient_environment",
"production_environment",
"payload",
"workflow_run",
"discussion",
"comment",
"child_comment_count",
"discussion_id",
"parent_id",
"column_url",
"commit_id",
"pull_request_url",
"browser_download_url",
"download_count",
"uploader",
"discussion_url",
"alert",
"manifest",
"installation_command",
"version",
"admin_enforced",
"authorized_actor_names",
"authorized_actors_only",
"authorized_dismissal_actors_only",
"linear_history_requirement_enforcement_level",
"required_status_checks",
"required_status_checks_enforcement_level",
"check_run",
"details_url",
"external_id",
"actions_meta",
"check_runs_url",
"latest_check_runs_count",
"rerequestable",
"runs_rerequestable",
"workflow",
"display_title",
"project_card",
"content_url",
"short_description",
"archived_at",
"reason",
"diff_hunk",
"in_reply_to_id",
"line",
"original_commit_id",
"original_line",
"original_position",
"original_start_line",
"position",
"pull_request_review_id",
"side",
"start_line",
"start_side",
"ref_type",
"target_url",
"new_repository",
"pusher",
"added",
"distinct",
"modified",
"removed",
"registry_package",
"ecosystem",
"namespace",
"package_type",
"package_version",
"body_html",
"docker_metadata",
"package_files",
"download_url",
"md5",
"sha1",
"sha256",
"package_url",
"rubygems_metadata",
"target_oid",
"registry",
"dismiss_comment"
]
@@ -111,7 +111,7 @@ jobs:
on: push on: push
jobs: jobs:
a: a:
uses: ./reusable-workflow-with-outputs.yaml uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
b: b:
needs: [a] needs: [a]
@@ -14,7 +14,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow-with-inputs.yaml uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with: with:
us|ername: us|ername:
`; `;
@@ -31,7 +31,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow-with-inputs-no-description.yaml uses: ./.github/workflows/reusable-workflow-with-inputs-no-description.yaml
with: with:
us|ername: us|ername:
`; `;
@@ -48,7 +48,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow-with-outputs.yaml uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
echo_outputs: echo_outputs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
+2 -5
View File
@@ -110,11 +110,8 @@ jobs:
`; `;
const result = await hover(...getPositionFromCursor(input)); const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined(); expect(result).not.toBeUndefined();
expect(result?.contents).toEqual( // Cron description is now shown via diagnostics, not hover
"Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00\n\n" + expect(result?.contents).toEqual("");
"Actions schedules run at most every 5 minutes. " +
"[Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
);
}); });
it("on a cron mapping key", async () => { it("on a cron mapping key", async () => {
+2 -24
View File
@@ -2,11 +2,9 @@ import {data, DescriptionDictionary, Parser} from "@actions/expressions";
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info"; import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
import {Lexer} from "@actions/expressions/lexer"; import {Lexer} from "@actions/expressions/lexer";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert"; import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context"; import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token"; import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {isBasicExpression, 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 {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider"; import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {Position, TextDocument} from "vscode-languageserver-textdocument"; import {Position, TextDocument} from "vscode-languageserver-textdocument";
@@ -23,7 +21,7 @@ import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-p
import {HoverVisitor} from "./expression-hover/visitor"; import {HoverVisitor} from "./expression-hover/visitor";
import {info} from "./log"; import {info} from "./log";
import {isPotentiallyExpression} from "./utils/expression-detection"; import {isPotentiallyExpression} from "./utils/expression-detection";
import {findToken, TokenResult} from "./utils/find-token"; import {findToken} from "./utils/find-token";
import {mapRange} from "./utils/range"; import {mapRange} from "./utils/range";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache"; import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
@@ -89,17 +87,6 @@ export async function hover(document: TextDocument, position: Position, config?:
info(`Calculating hover for token with definition ${token.definition.key}`); info(`Calculating hover for token with definition ${token.definition.key}`);
if (tokenResult.parent && isCronMappingValue(tokenResult)) {
const tokenValue = (token as StringToken).value;
const description = getCronDescription(tokenValue);
if (description) {
return {
contents: description,
range: mapRange(token.range)
} satisfies Hover;
}
}
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) { if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult); let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
description = appendContext(description, token.definitionInfo?.allowedContext); description = appendContext(description, token.definitionInfo?.allowedContext);
@@ -156,15 +143,6 @@ async function getDescription(
return description || defaultDescription; return description || defaultDescription;
} }
function isCronMappingValue(tokenResult: TokenResult): boolean {
return (
tokenResult.parent?.definition?.key === "cron-mapping" &&
!!tokenResult.token &&
isString(tokenResult.token) &&
tokenResult.token.value !== "cron"
);
}
function expressionHover( function expressionHover(
exprPos: ExpressionPos, exprPos: ExpressionPos,
context: DescriptionDictionary, context: DescriptionDictionary,
@@ -5,9 +5,9 @@ export const testFileProvider: FileProvider = {
// eslint-disable-next-line @typescript-eslint/require-await // eslint-disable-next-line @typescript-eslint/require-await
getFileContent: async ref => { getFileContent: async ref => {
switch (fileIdentifier(ref)) { switch (fileIdentifier(ref)) {
case "monalisa/octocat/workflow.yaml@main": case "monalisa/octocat/.github/workflows/workflow.yaml@main":
return { return {
name: "monalisa/octocat/workflow.yaml", name: "monalisa/octocat/.github/workflows/workflow.yaml",
content: ` content: `
on: workflow_call on: workflow_call
jobs: jobs:
@@ -31,9 +31,9 @@ jobs:
` `
}; };
case "./reusable-workflow.yaml": case "./.github/workflows/reusable-workflow.yaml":
return { return {
name: "reusable-workflow.yaml", name: ".github/workflows/reusable-workflow.yaml",
content: ` content: `
on: workflow_call on: workflow_call
jobs: jobs:
@@ -44,9 +44,9 @@ jobs:
` `
}; };
case "./reusable-workflow-with-inputs.yaml": case "./.github/workflows/reusable-workflow-with-inputs.yaml":
return { return {
name: "reusable-workflow-with-inputs.yaml", name: ".github/workflows/reusable-workflow-with-inputs.yaml",
content: ` content: `
on: on:
workflow_call: 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 { return {
name: "reusable-workflow-with-inputs.yaml", name: ".github/workflows/reusable-workflow-with-inputs.yaml",
content: ` content: `
on: on:
workflow_call: workflow_call:
@@ -95,9 +95,9 @@ jobs:
` `
}; };
case "./reusable-workflow-with-outputs.yaml": case "./.github/workflows/reusable-workflow-with-outputs.yaml":
return { return {
name: "reusable-workflow-with-outputs.yaml", name: ".github/workflows/reusable-workflow-with-outputs.yaml",
content: ` content: `
on: on:
workflow_call: workflow_call:
@@ -635,7 +635,7 @@ jobs:
fail-fast: true fail-fast: true
matrix: matrix:
node: [14, 16] node: [14, 16]
uses: ./reusable-workflow-with-inputs.yaml uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with: with:
username: User-\${{ strategy.fail-fast }} username: User-\${{ strategy.fail-fast }}
`; `;
@@ -654,7 +654,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node: [14, 16] node: [14, 16]
uses: ./reusable-workflow-with-inputs.yaml uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with: with:
username: \${{ matrix.node }} username: \${{ matrix.node }}
`; `;
+91 -1
View File
@@ -181,7 +181,7 @@ jobs:
expect(result.length).toBe(1); expect(result.length).toBe(1);
expect(result[0]).toEqual({ expect(result[0]).toEqual({
message: "Invalid cron string", message: "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
range: { range: {
end: { end: {
character: 21, character: 21,
@@ -195,6 +195,96 @@ jobs:
} as Diagnostic); } as Diagnostic);
}); });
it("cron with interval less than 5 minutes shows warning", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on:
schedule:
- cron: '*/1 * * * *'
jobs:
build:
runs-on: ubuntu-latest`
),
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(1);
expect(result[0]).toEqual({
message:
'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 () => { it("invalid YAML", async () => {
// This YAML has some mismatched single-quotes, which causes the string to be terminated early // This YAML has some mismatched single-quotes, which causes the string to be terminated early
// within the fromJSON() expression. // within the fromJSON() expression.
+378
View File
@@ -2,6 +2,7 @@ import {Lexer, Parser, data} from "@actions/expressions";
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast"; import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser"; import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert"; import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition"; import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context"; import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token"; import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
@@ -27,6 +28,9 @@ import {validateAction} from "./validate-action";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config"; import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
import {defaultValueProviders} from "./value-providers/default"; import {defaultValueProviders} from "./value-providers/default";
const CRON_SCHEDULE_DOCS_URL =
"https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule";
export type ValidationConfig = { export type ValidationConfig = {
valueProviderConfig?: ValueProviderConfig; valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig; contextProviderConfig?: ContextProviderConfig;
@@ -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) { if (token.definition?.key === "regular-step" && token.range) {
const context = getProviderContext(documentUri, template, root, token.range); const context = getProviderContext(documentUri, template, root, token.range);
await validateAction(diagnostics, token, context.step, config); await validateAction(diagnostics, token, context.step, config);
} }
// Validate job-level reusable workflow uses field format
if (
isString(token) &&
token.range &&
key &&
isString(key) &&
key.value === "uses" &&
parent?.definition?.key === "workflow-job"
) {
validateWorkflowUsesFormat(diagnostics, token);
}
// Validate cron expressions - warn if interval is less than 5 minutes
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
validateCronExpression(diagnostics, token);
}
// Allowed values coming from the schema have already been validated. Only check if // Allowed values coming from the schema have already been validated. Only check if
// a value provider is defined for a token and if it is, validate the values match. // a value provider is defined for a token and if it is, validate the values match.
if (token.range && validationDefinition) { if (token.range && validationDefinition) {
@@ -198,6 +225,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( function getProviderContext(
documentUri: URI, documentUri: URI,
template: WorkflowTemplate, template: WorkflowTemplate,
@@ -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/[email protected]
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/[email protected]': invalid character '..' in version",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 54}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version ending with dot", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1.
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1.': version cannot begin or end with a slash '/' or a dot '.'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 51}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version starting with slash", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@/v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@/v1': version cannot begin or end with a slash '/' or a dot '.'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 51}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version ending with .lock", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@refs/heads/main.lock
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@refs/heads/main.lock': invalid version: refs/heads/main.lock",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 68}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version with whitespace", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1 && rm -rf
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1 && rm -rf': version cannot have whitespace",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 60}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version with backslash", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1\\1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1\\1': invalid character '\\' in version",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 52}
},
code: "invalid-workflow-uses-format"
}
]);
});
});
describe("invalid owner/repo names", () => {
it("owner with invalid characters", async () => {
const input = `on: push
jobs:
test:
uses: owner*/repo/.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner*/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 51}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("repo with invalid characters", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo!name/.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo!name/.github/workflows/test.yml@v1': repository name is invalid",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 55}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("owner with spaces", async () => {
const input = `on: push
jobs:
test:
uses: owner name/repo/.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner name/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 55}
},
code: "invalid-workflow-uses-format"
}
]);
});
});
describe("invalid workflow filename", () => {
it("filename is just .yml", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yml@v1': invalid workflow file name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 46}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("filename is just .yaml", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/.yaml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yaml@v1': invalid workflow file name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 47}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("local workflow filename is just .yml", async () => {
const input = `on: push
jobs:
test:
uses: ./.github/workflows/.yml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference './.github/workflows/.yml': invalid workflow file name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 34}
},
code: "invalid-workflow-uses-format"
}
]);
});
});
});
});
@@ -43,7 +43,7 @@ on: push
jobs: jobs:
build: build:
uses: monalisa/octocat/workflow.yaml@not-a-branch uses: monalisa/octocat/.github/workflows/workflow.yaml@not-a-branch
`; `;
const result = await validate(createDocument("wf.yaml", input), { const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider fileProvider: testFileProvider
@@ -58,7 +58,7 @@ jobs:
line: 5 line: 5
}, },
end: { end: {
character: 53, character: 71,
line: 5 line: 5
} }
} }
@@ -72,7 +72,7 @@ on: push
jobs: jobs:
build: build:
uses: monalisa/octocat/workflow.yaml@main uses: monalisa/octocat/.github/workflows/workflow.yaml@main
`; `;
const result = await validate(createDocument("wf.yaml", input), { const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider fileProvider: testFileProvider
@@ -87,7 +87,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow.yaml uses: ./.github/workflows/reusable-workflow.yaml
`; `;
const result = await validate(createDocument("wf.yaml", input), { const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider fileProvider: testFileProvider
@@ -102,7 +102,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow-with-inputs.yaml uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets: secrets:
envPAT: pat envPAT: pat
`; `;
@@ -119,7 +119,7 @@ jobs:
line: 5 line: 5
}, },
end: { end: {
character: 46, character: 64,
line: 5 line: 5
} }
} }
@@ -133,7 +133,7 @@ on: push
jobs: jobs:
build: build:
uses: ./reusable-workflow-with-inputs.yaml uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with: with:
username: monalisa username: monalisa
secrets: secrets:
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice", "languageservice",
"languageserver" "languageserver"
], ],
"version": "0.3.20" "version": "0.3.23"
} }
+84 -167
View File
@@ -135,7 +135,7 @@
}, },
"expressions": { "expressions": {
"name": "@actions/expressions", "name": "@actions/expressions",
"version": "0.3.20", "version": "0.3.23",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.0.3", "@types/jest": "^29.0.3",
@@ -340,9 +340,9 @@
} }
}, },
"expressions/node_modules/js-yaml": { "expressions/node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -395,11 +395,11 @@
}, },
"languageserver": { "languageserver": {
"name": "@actions/languageserver", "name": "@actions/languageserver",
"version": "0.3.20", "version": "0.3.23",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/languageservice": "^0.3.20", "@actions/languageservice": "^0.3.23",
"@actions/workflow-parser": "^0.3.20", "@actions/workflow-parser": "^0.3.23",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0", "@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2", "vscode-languageserver": "^8.0.2",
@@ -921,11 +921,11 @@
}, },
"languageservice": { "languageservice": {
"name": "@actions/languageservice", "name": "@actions/languageservice",
"version": "0.3.20", "version": "0.3.23",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/expressions": "^0.3.20", "@actions/expressions": "^0.3.23",
"@actions/workflow-parser": "^0.3.20", "@actions/workflow-parser": "^0.3.23",
"vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2", "vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8", "vscode-uri": "^3.0.8",
@@ -1136,9 +1136,9 @@
} }
}, },
"languageservice/node_modules/js-yaml": { "languageservice/node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -1218,89 +1218,19 @@
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.22.13", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@babel/highlight": "^7.22.13", "@babel/helper-validator-identifier": "^7.27.1",
"chalk": "^2.4.2" "js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/code-frame/node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/code-frame/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/code-frame/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/@babel/code-frame/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true
},
"node_modules/@babel/code-frame/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@babel/code-frame/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/code-frame/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/compat-data": { "node_modules/@babel/compat-data": {
"version": "7.20.1", "version": "7.20.1",
"dev": true, "dev": true,
@@ -1483,18 +1413,18 @@
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.22.5", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.22.20", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -1509,13 +1439,13 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.20.1", "version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.18.10", "@babel/template": "^7.27.2",
"@babel/traverse": "^7.20.1", "@babel/types": "^7.28.4"
"@babel/types": "^7.20.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -1600,10 +1530,13 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.23.0", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"dev": true, "dev": true,
"dependencies": {
"@babel/types": "^7.28.5"
},
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
}, },
@@ -1786,14 +1719,14 @@
} }
}, },
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.22.15", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.22.13", "@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.22.15", "@babel/parser": "^7.27.2",
"@babel/types": "^7.22.15" "@babel/types": "^7.27.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -1821,14 +1754,13 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.23.0", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.22.5", "@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.22.20", "@babel/helper-validator-identifier": "^7.28.5"
"to-fast-properties": "^2.0.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -4231,12 +4163,10 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
"version": "7.3.8", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@@ -4361,12 +4291,10 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.3.8", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@@ -4402,12 +4330,10 @@
} }
}, },
"node_modules/@typescript-eslint/utils/node_modules/semver": { "node_modules/@typescript-eslint/utils/node_modules/semver": {
"version": "7.3.8", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@@ -4877,9 +4803,10 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@@ -6265,12 +6192,10 @@
} }
}, },
"node_modules/eslint/node_modules/semver": { "node_modules/eslint/node_modules/semver": {
"version": "7.3.8", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@@ -8392,12 +8317,10 @@
} }
}, },
"node_modules/jest-snapshot/node_modules/semver": { "node_modules/jest-snapshot/node_modules/semver": {
"version": "7.3.8", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@@ -8510,9 +8433,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "3.14.1", "version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^1.0.7", "argparse": "^1.0.7",
"esprima": "^4.0.0" "esprima": "^4.0.0"
@@ -9514,11 +9438,12 @@
} }
}, },
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.5", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"braces": "^3.0.2", "braces": "^3.0.3",
"picomatch": "^2.3.1" "picomatch": "^2.3.1"
}, },
"engines": { "engines": {
@@ -10760,9 +10685,10 @@
} }
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.0.0", "version": "1.1.1",
"dev": true, "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"license": "ISC" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
@@ -11524,9 +11450,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.0", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true, "dev": true,
"license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
@@ -12147,14 +12074,6 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/to-fast-properties": {
"version": "2.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -12235,12 +12154,10 @@
} }
}, },
"node_modules/ts-jest/node_modules/semver": { "node_modules/ts-jest/node_modules/semver": {
"version": "7.3.8", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@@ -12917,10 +12834,10 @@
}, },
"workflow-parser": { "workflow-parser": {
"name": "@actions/workflow-parser", "name": "@actions/workflow-parser",
"version": "0.3.20", "version": "0.3.23",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/expressions": "^0.3.20", "@actions/expressions": "^0.3.23",
"cronstrue": "^2.21.0", "cronstrue": "^2.21.0",
"yaml": "^2.0.0-8" "yaml": "^2.0.0-8"
}, },
+1
View File
@@ -1,6 +1,7 @@
{ {
"name": "actions-languageservices", "name": "actions-languageservices",
"private": true, "private": true,
"type": "module",
"workspaces": [ "workspaces": [
"./expressions", "./expressions",
"./workflow-parser", "./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", "name": "@actions/workflow-parser",
"version": "0.3.20", "version": "0.3.23",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"source": "./src/index.ts", "source": "./src/index.ts",
@@ -38,19 +38,22 @@
"format-check": "prettier --check '**/*.ts'", "format-check": "prettier --check '**/*.ts'",
"lint": "eslint 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix '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", "prepublishOnly": "npm run build && npm run test",
"pretest": "npm run minify-json",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest", "test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-xlang": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathPattern xlang", "test-xlang": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathPattern xlang",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch", "test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"dependencies": { "dependencies": {
"@actions/expressions": "^0.3.20", "@actions/expressions": "^0.3.23",
"cronstrue": "^2.21.0", "cronstrue": "^2.21.0",
"yaml": "^2.0.0-8" "yaml": "^2.0.0-8"
}, },
"engines": { "engines": {
"node": ">= 16.15" "node": ">= 18"
}, },
"files": [ "files": [
"dist/**/*" "dist/**/*"
@@ -1,4 +1,4 @@
import {isValidCron, getCronDescription} from "./cron"; import {isValidCron, getCronDescription, hasCronIntervalLessThan5Minutes} from "./cron";
describe("cron", () => { describe("cron", () => {
describe("valid cron", () => { describe("valid cron", () => {
@@ -66,14 +66,54 @@ describe("cron", () => {
describe("getCronDescription", () => { describe("getCronDescription", () => {
it(`Produces a sentence for valid cron`, () => { it(`Produces a sentence for valid cron`, () => {
expect(getCronDescription("0 * * * *")).toEqual( expect(getCronDescription("0 * * * *")).toEqual("Runs every hour");
"Runs every hour\n\n" +
"Actions schedules run at most every 5 minutes. [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
);
}); });
it(`Returns nothing for invalid cron`, () => { it(`Returns nothing for invalid cron`, () => {
expect(getCronDescription("* * * * * *")).toBeUndefined(); expect(getCronDescription("* * * * * *")).toBeUndefined();
}); });
}); });
describe("hasCronIntervalLessThan5Minutes", () => {
it("returns true for step expressions with interval < 5 min", () => {
expect(hasCronIntervalLessThan5Minutes("*/1 * * * *")).toBe(true);
expect(hasCronIntervalLessThan5Minutes("*/4 * * * *")).toBe(true);
});
it("returns false for step expressions with interval >= 5 min", () => {
expect(hasCronIntervalLessThan5Minutes("*/5 * * * *")).toBe(false);
expect(hasCronIntervalLessThan5Minutes("*/15 * * * *")).toBe(false);
});
it("returns true for comma-separated values with gap < 5 min", () => {
expect(hasCronIntervalLessThan5Minutes("0,2,4 * * * *")).toBe(true);
expect(hasCronIntervalLessThan5Minutes("0,10,12 * * * *")).toBe(true);
});
it("returns false for comma-separated values with gap >= 5 min", () => {
expect(hasCronIntervalLessThan5Minutes("0,10,20 * * * *")).toBe(false);
expect(hasCronIntervalLessThan5Minutes("0,30 * * * *")).toBe(false);
});
it("returns true for comma-separated values with wrap-around gap < 5 min", () => {
expect(hasCronIntervalLessThan5Minutes("0,58 * * * *")).toBe(true);
expect(hasCronIntervalLessThan5Minutes("2,59 * * * *")).toBe(true);
});
it("returns true for * (every minute)", () => {
expect(hasCronIntervalLessThan5Minutes("* * * * *")).toBe(true);
});
it("returns true for range expressions (runs every minute in range)", () => {
expect(hasCronIntervalLessThan5Minutes("0-4 * * * *")).toBe(true);
});
it("returns false for single value (hourly)", () => {
expect(hasCronIntervalLessThan5Minutes("0 * * * *")).toBe(false);
});
it("returns false for invalid cron", () => {
expect(hasCronIntervalLessThan5Minutes("invalid")).toBe(false);
});
});
}); });
+73 -5
View File
@@ -8,6 +8,78 @@ type Range = {
names?: Record<string, number>; names?: Record<string, number>;
}; };
/**
* Checks if a cron expression specifies an interval shorter than 5 minutes.
* GitHub Actions schedules run at most every 5 minutes, so intervals < 5 min won't work as expected.
*/
export function hasCronIntervalLessThan5Minutes(cron: string): boolean {
if (!isValidCron(cron)) {
return false;
}
const parts = cron.split(/ +/);
const minutePart = parts[0];
// Parse the minute field to determine the effective interval
return getMinuteInterval(minutePart) < 5;
}
/**
* Gets the minimum interval in minutes between cron executions based on the minute field.
* Returns 60 if there's only one execution per hour, otherwise returns the minimum gap.
*/
function getMinuteInterval(minutePart: string): number {
// Handle step expressions like */1, */3, 0-59/2
if (minutePart.includes("/")) {
const [, step] = minutePart.split("/");
const stepNum = parseInt(step, 10);
if (!isNaN(stepNum) && stepNum > 0) {
return stepNum;
}
}
// Handle comma-separated values like 0,2,4 or 0,1,5,10
if (minutePart.includes(",")) {
const values = minutePart
.split(",")
.map(v => parseInt(v, 10))
.filter(n => !isNaN(n))
.sort((a, b) => a - b);
if (values.length >= 2) {
let minGap = 60;
for (let i = 1; i < values.length; i++) {
const gap = values[i] - values[i - 1];
if (gap < minGap) {
minGap = gap;
}
}
// Check wrap-around gap from last minute to first minute of next hour
const wrapGap = values[0] + 60 - values[values.length - 1];
if (wrapGap < minGap) {
minGap = wrapGap;
}
return minGap;
}
}
// Handle range expressions like 0-4 (runs every minute from 0-4)
if (minutePart.includes("-") && !minutePart.includes("/")) {
const [start, end] = minutePart.split("-").map(v => parseInt(v, 10));
if (!isNaN(start) && !isNaN(end) && end > start) {
// A range without step means every minute in that range
return 1;
}
}
// * means every minute
if (minutePart === "*") {
return 1;
}
// Single value or unrecognized pattern - assume hourly (60 min interval)
return 60;
}
export function isValidCron(cron: string): boolean { export function isValidCron(cron: string): boolean {
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
@@ -46,11 +118,7 @@ export function getCronDescription(cronspec: string): string | undefined {
} }
// Make first character lowercase // Make first character lowercase
let result = "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1); return "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
result +=
"\n\nActions schedules run at most every 5 minutes." +
" [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)";
return result;
} }
function validateCronPart(value: string, range: Range, allowSeparators = true): boolean { function validateCronPart(value: string, range: Range, allowSeparators = true): boolean {
@@ -158,7 +158,7 @@ function convertSchedule(context: TemplateContext, token: SequenceToken): Schedu
const cron = schedule.value.assertString(`schedule cron`); const cron = schedule.value.assertString(`schedule cron`);
// Validate the cron string // Validate the cron string
if (!isValidCron(cron.value)) { if (!isValidCron(cron.value)) {
context.error(cron, "Invalid cron string"); context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
} }
result.push({cron: cron.value}); result.push({cron: cron.value});
} else { } else {
@@ -1,6 +1,6 @@
import {JSONObjectReader} from "../templates/json-object-reader"; import {JSONObjectReader} from "../templates/json-object-reader";
import {TemplateSchema} from "../templates/schema"; import {TemplateSchema} from "../templates/schema";
import WorkflowSchema from "../workflow-v1.0.json"; import WorkflowSchema from "../workflow-v1.0.min.json";
let schema: TemplateSchema; let schema: TemplateSchema;