Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38ffd53f3b | |||
| 7660f61777 | |||
| c04c1b26f4 | |||
| 4429c41275 | |||
| 7b9adb106e | |||
| 576402fc01 | |||
| 22c36bc946 | |||
| 4dd678cf30 | |||
| dfb411f71e | |||
| dec597b0db | |||
| bd7e5f0b70 | |||
| 37ba6ab105 | |||
| 216fcbb8c4 |
@@ -12,18 +12,85 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 16.15
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.15
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
- run: npm ci --engine-strict
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: npm run format-check -ws
|
||||
- run: npm run build -ws
|
||||
- run: npm run lint -ws
|
||||
- run: npm test -ws
|
||||
|
||||
check-generated:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Regenerate JSON files
|
||||
run: |
|
||||
cd languageservice && npm run update-webhooks && cd ..
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "ERROR: Generated files are out of date!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Please run the following commands locally and commit the changes:"
|
||||
echo ""
|
||||
echo " cd languageservice && npm run update-webhooks && cd .."
|
||||
echo " git add -A && git commit -m 'Regenerate JSON files'"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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)"
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.x
|
||||
node-version: 22.x
|
||||
cache: "npm"
|
||||
scope: '@actions'
|
||||
|
||||
|
||||
+13
-1
@@ -2,4 +2,16 @@
|
||||
*/dist
|
||||
lerna-debug.log
|
||||
node_modules
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Minified JSON (generated at build time)
|
||||
*.min.json
|
||||
|
||||
# Optimized workflow schema (generated by optimize-workflow-schema.js)
|
||||
*.optimized.json
|
||||
|
||||
# Full webhooks source (generated by update-webhooks, used for validation tests)
|
||||
*.full.json
|
||||
|
||||
# Validation marker (generated by tests)
|
||||
*.validation-complete
|
||||
|
||||
@@ -8,6 +8,10 @@ This repository contains multiple npm packages for working with GitHub Actions w
|
||||
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
|
||||
- [browser-playground](./browser-playground) - Browser-based playground for the language service
|
||||
|
||||
## Documentation
|
||||
|
||||
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
|
||||
|
||||
### Note
|
||||
|
||||
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
|
||||
|
||||
@@ -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%** |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.23",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -44,7 +44,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.23",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -43,8 +43,8 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.20",
|
||||
"@actions/workflow-parser": "^0.3.20",
|
||||
"@actions/languageservice": "^0.3.23",
|
||||
"@actions/workflow-parser": "^0.3.23",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -52,7 +52,7 @@
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.23",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -37,22 +37,25 @@
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/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",
|
||||
"pretest": "npm run minify-json",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||
"update-webhooks": "ts-node-esm script/webhooks/index.ts",
|
||||
"update-webhooks": "npx tsx script/webhooks/update-webhooks.ts",
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.20",
|
||||
"@actions/workflow-parser": "^0.3.20",
|
||||
"@actions/expressions": "^0.3.23",
|
||||
"@actions/workflow-parser": "^0.3.23",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
"yaml": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
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
|
||||
// and replace them in the webhook with an index in the array
|
||||
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> = {};
|
||||
|
||||
for (const webhook of iterateWebhooks(webhooks)) {
|
||||
for (const param of webhook.bodyParameters) {
|
||||
objectsByName[param.name] ||= [];
|
||||
const index = findOrAdd(param, objectsByName[param.name]);
|
||||
const key = `${param.name}:${index}`;
|
||||
for (const param of getParams(webhook)) {
|
||||
const name = getParamName(param);
|
||||
objectsByName[name] ||= [];
|
||||
const index = findOrAdd(param, objectsByName[name]);
|
||||
const key = `${name}:${index}`;
|
||||
objectCount[key] ||= 0;
|
||||
objectCount[key]++;
|
||||
}
|
||||
@@ -27,18 +61,19 @@ export function deduplicateWebhooks(webhooks: Record<string, Record<string, Webh
|
||||
|
||||
for (const webhook of iterateWebhooks(webhooks)) {
|
||||
const newParams: any[] = [];
|
||||
for (const param of webhook.bodyParameters) {
|
||||
const index = find(param, objectsByName[param.name]);
|
||||
const key = `${param.name}:${index}`;
|
||||
for (const param of getParams(webhook)) {
|
||||
const name = getParamName(param);
|
||||
const index = find(param, objectsByName[name]);
|
||||
const key = `${name}:${index}`;
|
||||
if (objectCount[key] > 1) {
|
||||
newParams.push(indexForParam(param, index, bodyParamIndexMap, duplicatedBodyParams));
|
||||
newParams.push(indexForParam(param, name, index, bodyParamIndexMap, duplicatedBodyParams));
|
||||
} else {
|
||||
// If an object is only used once, keep it inline
|
||||
newParams.push(param);
|
||||
}
|
||||
}
|
||||
|
||||
webhook.bodyParameters = newParams;
|
||||
setParams(webhook, newParams);
|
||||
}
|
||||
|
||||
return duplicatedBodyParams;
|
||||
@@ -74,11 +109,12 @@ function find(param: any, objects: any[]): number {
|
||||
|
||||
function indexForParam(
|
||||
param: any,
|
||||
paramName: string,
|
||||
paramNameIndex: number,
|
||||
objectIndexMap: Record<string, number>,
|
||||
duplicatedBodyParams: any[]
|
||||
): number {
|
||||
const key = `${param.name}:${paramNameIndex}`;
|
||||
const key = `${paramName}:${paramNameIndex}`;
|
||||
|
||||
const existingIndex = objectIndexMap[key];
|
||||
if (existingIndex !== undefined) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
jobs:
|
||||
a:
|
||||
uses: ./reusable-workflow-with-outputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||
b:
|
||||
needs: [a]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -21,7 +21,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
|
|
||||
`;
|
||||
@@ -49,7 +49,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
username: monalisa
|
||||
|
|
||||
@@ -74,7 +74,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets:
|
||||
|
|
||||
`;
|
||||
@@ -102,7 +102,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets: |
|
||||
`;
|
||||
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
|
||||
@@ -117,7 +117,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets:
|
||||
envPAT: "myPAT"
|
||||
|
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import descriptions from "./descriptions.json";
|
||||
import descriptions from "./descriptions.min.json";
|
||||
|
||||
export const RootContext = "root";
|
||||
const FunctionContext = "functions";
|
||||
|
||||
@@ -0,0 +1,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 webhookObjects from "./objects.json";
|
||||
import webhooks from "./webhooks.json";
|
||||
import webhooksData from "./webhooks.min.json";
|
||||
import objectsData from "./webhooks.objects.min.json";
|
||||
import stringsData from "./webhooks.strings.min.json";
|
||||
|
||||
import schedule from "./schedule.json";
|
||||
import workflow_call from "./workflow_call.json";
|
||||
import schedule from "./schedule.min.json";
|
||||
import workflow_call from "./workflow_call.min.json";
|
||||
|
||||
const customEventPayloads: {[name: string]: unknown} = {
|
||||
schedule,
|
||||
@@ -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 = {
|
||||
descriptionHtml: string;
|
||||
@@ -65,17 +79,33 @@ type Webhooks = {
|
||||
};
|
||||
};
|
||||
|
||||
type DeduplicatedWebhooks = {
|
||||
[name: string]: {
|
||||
[action: string]: WebhookPayload & {
|
||||
bodyParameters: DeduplicatedParam[];
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Webhooks data format after optimization:
|
||||
* {
|
||||
* [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 */
|
||||
const dedupedWebhookPayloads: DeduplicatedWebhooks = webhooks as any;
|
||||
const objects: Param[] = webhookObjects as any;
|
||||
const webhooksJson: WebhooksData = webhooksData 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 */
|
||||
|
||||
// Hydrated webhook payloads
|
||||
@@ -169,10 +199,14 @@ function getWebhookPayload(event: string, action: string): WebhookPayload | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get params from 'p' (compact format)
|
||||
const dedupedParams = deduplicatedPayload.p || [];
|
||||
|
||||
// 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 = {
|
||||
...deduplicatedPayload,
|
||||
descriptionHtml: "",
|
||||
summaryHtml: "",
|
||||
bodyParameters: params
|
||||
};
|
||||
webhookPayloads[event] ||= {};
|
||||
@@ -180,13 +214,65 @@ function getWebhookPayload(event: string, action: string): WebhookPayload | unde
|
||||
return payload;
|
||||
}
|
||||
|
||||
function fullParam(dedupedParam: DeduplicatedParam): Param {
|
||||
if (typeof dedupedParam === "number") {
|
||||
if (dedupedParam >= objects.length) {
|
||||
throw new Error(`Unknown object ${dedupedParam}`);
|
||||
/**
|
||||
* Resolve an interned name (non-negative number -> string table lookup) or return literal string
|
||||
*/
|
||||
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
|
||||
jobs:
|
||||
a:
|
||||
uses: ./reusable-workflow-with-outputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||
|
||||
b:
|
||||
needs: [a]
|
||||
|
||||
@@ -14,7 +14,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
us|ername:
|
||||
`;
|
||||
@@ -31,7 +31,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs-no-description.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs-no-description.yaml
|
||||
with:
|
||||
us|ername:
|
||||
`;
|
||||
@@ -48,7 +48,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-outputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||
echo_outputs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
|
||||
@@ -110,11 +110,8 @@ jobs:
|
||||
`;
|
||||
const result = await hover(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result?.contents).toEqual(
|
||||
"Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00\n\n" +
|
||||
"Actions schedules run at most every 5 minutes. " +
|
||||
"[Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
|
||||
);
|
||||
// Cron description is now shown via diagnostics, not hover
|
||||
expect(result?.contents).toEqual("");
|
||||
});
|
||||
|
||||
it("on a cron mapping key", async () => {
|
||||
|
||||
@@ -2,11 +2,9 @@ import {data, DescriptionDictionary, Parser} from "@actions/expressions";
|
||||
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
|
||||
import {Lexer} from "@actions/expressions/lexer";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
|
||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {isBasicExpression, isString} from "@actions/workflow-parser/templates/tokens/type-guards";
|
||||
import {isBasicExpression} from "@actions/workflow-parser/templates/tokens/type-guards";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||
@@ -23,7 +21,7 @@ import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-p
|
||||
import {HoverVisitor} from "./expression-hover/visitor";
|
||||
import {info} from "./log";
|
||||
import {isPotentiallyExpression} from "./utils/expression-detection";
|
||||
import {findToken, TokenResult} from "./utils/find-token";
|
||||
import {findToken} from "./utils/find-token";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||
|
||||
@@ -89,17 +87,6 @@ export async function hover(document: TextDocument, position: Position, config?:
|
||||
|
||||
info(`Calculating hover for token with definition ${token.definition.key}`);
|
||||
|
||||
if (tokenResult.parent && isCronMappingValue(tokenResult)) {
|
||||
const tokenValue = (token as StringToken).value;
|
||||
const description = getCronDescription(tokenValue);
|
||||
if (description) {
|
||||
return {
|
||||
contents: description,
|
||||
range: mapRange(token.range)
|
||||
} satisfies Hover;
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
||||
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
|
||||
description = appendContext(description, token.definitionInfo?.allowedContext);
|
||||
@@ -156,15 +143,6 @@ async function getDescription(
|
||||
return description || defaultDescription;
|
||||
}
|
||||
|
||||
function isCronMappingValue(tokenResult: TokenResult): boolean {
|
||||
return (
|
||||
tokenResult.parent?.definition?.key === "cron-mapping" &&
|
||||
!!tokenResult.token &&
|
||||
isString(tokenResult.token) &&
|
||||
tokenResult.token.value !== "cron"
|
||||
);
|
||||
}
|
||||
|
||||
function expressionHover(
|
||||
exprPos: ExpressionPos,
|
||||
context: DescriptionDictionary,
|
||||
|
||||
@@ -5,9 +5,9 @@ export const testFileProvider: FileProvider = {
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
getFileContent: async ref => {
|
||||
switch (fileIdentifier(ref)) {
|
||||
case "monalisa/octocat/workflow.yaml@main":
|
||||
case "monalisa/octocat/.github/workflows/workflow.yaml@main":
|
||||
return {
|
||||
name: "monalisa/octocat/workflow.yaml",
|
||||
name: "monalisa/octocat/.github/workflows/workflow.yaml",
|
||||
content: `
|
||||
on: workflow_call
|
||||
jobs:
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
`
|
||||
};
|
||||
|
||||
case "./reusable-workflow.yaml":
|
||||
case "./.github/workflows/reusable-workflow.yaml":
|
||||
return {
|
||||
name: "reusable-workflow.yaml",
|
||||
name: ".github/workflows/reusable-workflow.yaml",
|
||||
content: `
|
||||
on: workflow_call
|
||||
jobs:
|
||||
@@ -44,9 +44,9 @@ jobs:
|
||||
`
|
||||
};
|
||||
|
||||
case "./reusable-workflow-with-inputs.yaml":
|
||||
case "./.github/workflows/reusable-workflow-with-inputs.yaml":
|
||||
return {
|
||||
name: "reusable-workflow-with-inputs.yaml",
|
||||
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
|
||||
content: `
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -76,9 +76,9 @@ jobs:
|
||||
`
|
||||
};
|
||||
|
||||
case "./reusable-workflow-with-inputs-no-description.yaml":
|
||||
case "./.github/workflows/reusable-workflow-with-inputs-no-description.yaml":
|
||||
return {
|
||||
name: "reusable-workflow-with-inputs.yaml",
|
||||
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
|
||||
content: `
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -95,9 +95,9 @@ jobs:
|
||||
`
|
||||
};
|
||||
|
||||
case "./reusable-workflow-with-outputs.yaml":
|
||||
case "./.github/workflows/reusable-workflow-with-outputs.yaml":
|
||||
return {
|
||||
name: "reusable-workflow-with-outputs.yaml",
|
||||
name: ".github/workflows/reusable-workflow-with-outputs.yaml",
|
||||
content: `
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -635,7 +635,7 @@ jobs:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
node: [14, 16]
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
username: User-\${{ strategy.fail-fast }}
|
||||
`;
|
||||
@@ -654,7 +654,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [14, 16]
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
username: \${{ matrix.node }}
|
||||
`;
|
||||
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toEqual({
|
||||
message: "Invalid cron string",
|
||||
message: "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
|
||||
range: {
|
||||
end: {
|
||||
character: 21,
|
||||
@@ -195,6 +195,96 @@ jobs:
|
||||
} as Diagnostic);
|
||||
});
|
||||
|
||||
it("cron with interval less than 5 minutes shows warning", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
`on:
|
||||
schedule:
|
||||
- cron: '*/1 * * * *'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
),
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toEqual({
|
||||
message:
|
||||
'Actions schedules run at most every 5 minutes. "*/1 * * * *" (runs every minute) will not run as frequently as specified.',
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
code: "on-schedule",
|
||||
codeDescription: {
|
||||
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
|
||||
},
|
||||
range: {
|
||||
end: {
|
||||
character: 25,
|
||||
line: 2
|
||||
},
|
||||
start: {
|
||||
character: 12,
|
||||
line: 2
|
||||
}
|
||||
}
|
||||
} as Diagnostic);
|
||||
});
|
||||
|
||||
it("cron with interval of 5 minutes or more shows info", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
`on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
),
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toEqual({
|
||||
message: "Runs every 5 minutes",
|
||||
severity: DiagnosticSeverity.Information,
|
||||
code: "on-schedule",
|
||||
codeDescription: {
|
||||
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
|
||||
},
|
||||
range: {
|
||||
end: {
|
||||
character: 25,
|
||||
line: 2
|
||||
},
|
||||
start: {
|
||||
character: 12,
|
||||
line: 2
|
||||
}
|
||||
}
|
||||
} as Diagnostic);
|
||||
});
|
||||
|
||||
it("cron with comma-separated minutes less than 5 apart shows warning", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
`on:
|
||||
schedule:
|
||||
- cron: '0,2 * * * *'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
),
|
||||
{valueProviderConfig: defaultValueProviders}
|
||||
);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.severity).toBe(DiagnosticSeverity.Warning);
|
||||
expect(result[0]?.message).toContain("Actions schedules run at most every 5 minutes.");
|
||||
});
|
||||
|
||||
it("invalid YAML", async () => {
|
||||
// This YAML has some mismatched single-quotes, which causes the string to be terminated early
|
||||
// within the fromJSON() expression.
|
||||
|
||||
@@ -2,6 +2,7 @@ import {Lexer, Parser, data} from "@actions/expressions";
|
||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
||||
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
|
||||
@@ -27,6 +28,9 @@ import {validateAction} from "./validate-action";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||
import {defaultValueProviders} from "./value-providers/default";
|
||||
|
||||
const CRON_SCHEDULE_DOCS_URL =
|
||||
"https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule";
|
||||
|
||||
export type ValidationConfig = {
|
||||
valueProviderConfig?: ValueProviderConfig;
|
||||
contextProviderConfig?: ContextProviderConfig;
|
||||
@@ -143,11 +147,34 @@ async function additionalValidations(
|
||||
}
|
||||
}
|
||||
|
||||
// Validate step uses field format
|
||||
if (isString(token) && token.range && validationDefinition?.key === "step-uses") {
|
||||
validateStepUsesFormat(diagnostics, token);
|
||||
}
|
||||
|
||||
// Validate action metadata (inputs, required fields) for regular steps
|
||||
if (token.definition?.key === "regular-step" && token.range) {
|
||||
const context = getProviderContext(documentUri, template, root, token.range);
|
||||
await validateAction(diagnostics, token, context.step, config);
|
||||
}
|
||||
|
||||
// Validate job-level reusable workflow uses field format
|
||||
if (
|
||||
isString(token) &&
|
||||
token.range &&
|
||||
key &&
|
||||
isString(key) &&
|
||||
key.value === "uses" &&
|
||||
parent?.definition?.key === "workflow-job"
|
||||
) {
|
||||
validateWorkflowUsesFormat(diagnostics, token);
|
||||
}
|
||||
|
||||
// Validate cron expressions - warn if interval is less than 5 minutes
|
||||
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
|
||||
validateCronExpression(diagnostics, token);
|
||||
}
|
||||
|
||||
// Allowed values coming from the schema have already been validated. Only check if
|
||||
// a value provider is defined for a token and if it is, validate the values match.
|
||||
if (token.range && validationDefinition) {
|
||||
@@ -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(
|
||||
documentUri: URI,
|
||||
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/test.yml@v1..v2
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1..v2': invalid character '..' in version",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 54}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version ending with dot", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@v1.
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1.': version cannot begin or end with a slash '/' or a dot '.'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 51}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version starting with slash", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@/v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@/v1': version cannot begin or end with a slash '/' or a dot '.'",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 51}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version ending with .lock", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@refs/heads/main.lock
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@refs/heads/main.lock': invalid version: refs/heads/main.lock",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 68}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version with whitespace", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@v1 && rm -rf
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1 && rm -rf': version cannot have whitespace",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 60}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("version with backslash", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/test.yml@v1\\1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1\\1': invalid character '\\' in version",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 52}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid owner/repo names", () => {
|
||||
it("owner with invalid characters", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner*/repo/.github/workflows/test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner*/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 51}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("repo with invalid characters", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo!name/.github/workflows/test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner/repo!name/.github/workflows/test.yml@v1': repository name is invalid",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 55}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("owner with spaces", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner name/repo/.github/workflows/test.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message:
|
||||
"Invalid workflow reference 'owner name/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 55}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid workflow filename", () => {
|
||||
it("filename is just .yml", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/.yml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yml@v1': invalid workflow file name",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 46}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("filename is just .yaml", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: owner/repo/.github/workflows/.yaml@v1
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yaml@v1': invalid workflow file name",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 47}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("local workflow filename is just .yml", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/.yml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Invalid workflow reference './.github/workflows/.yml': invalid workflow file name",
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: {
|
||||
start: {line: 3, character: 10},
|
||||
end: {line: 3, character: 34}
|
||||
},
|
||||
code: "invalid-workflow-uses-format"
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: monalisa/octocat/workflow.yaml@not-a-branch
|
||||
uses: monalisa/octocat/.github/workflows/workflow.yaml@not-a-branch
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
line: 5
|
||||
},
|
||||
end: {
|
||||
character: 53,
|
||||
character: 71,
|
||||
line: 5
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: monalisa/octocat/workflow.yaml@main
|
||||
uses: monalisa/octocat/.github/workflows/workflow.yaml@main
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
@@ -87,7 +87,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow.yaml
|
||||
uses: ./.github/workflows/reusable-workflow.yaml
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), {
|
||||
fileProvider: testFileProvider
|
||||
@@ -102,7 +102,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets:
|
||||
envPAT: pat
|
||||
`;
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
line: 5
|
||||
},
|
||||
end: {
|
||||
character: 46,
|
||||
character: 64,
|
||||
line: 5
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
username: monalisa
|
||||
secrets:
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.20"
|
||||
"version": "0.3.23"
|
||||
}
|
||||
Generated
+84
-167
@@ -135,7 +135,7 @@
|
||||
},
|
||||
"expressions": {
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.23",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -340,9 +340,9 @@
|
||||
}
|
||||
},
|
||||
"expressions/node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -395,11 +395,11 @@
|
||||
},
|
||||
"languageserver": {
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.23",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.20",
|
||||
"@actions/workflow-parser": "^0.3.20",
|
||||
"@actions/languageservice": "^0.3.23",
|
||||
"@actions/workflow-parser": "^0.3.23",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -921,11 +921,11 @@
|
||||
},
|
||||
"languageservice": {
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.23",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.20",
|
||||
"@actions/workflow-parser": "^0.3.20",
|
||||
"@actions/expressions": "^0.3.23",
|
||||
"@actions/workflow-parser": "^0.3.23",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
@@ -1136,9 +1136,9 @@
|
||||
}
|
||||
},
|
||||
"languageservice/node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -1218,89 +1218,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.22.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
|
||||
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.22.13",
|
||||
"chalk": "^2.4.2"
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"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": {
|
||||
"version": "7.20.1",
|
||||
"dev": true,
|
||||
@@ -1483,18 +1413,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
|
||||
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
|
||||
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1509,13 +1439,13 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.18.10",
|
||||
"@babel/traverse": "^7.20.1",
|
||||
"@babel/types": "^7.20.0"
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1600,10 +1530,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
|
||||
"integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.5"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
@@ -1786,14 +1719,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.22.15",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
|
||||
"integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.22.13",
|
||||
"@babel/parser": "^7.22.15",
|
||||
"@babel/types": "^7.22.15"
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/parser": "^7.27.2",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1821,14 +1754,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
|
||||
"integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.22.5",
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -4231,12 +4163,10 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -4361,12 +4291,10 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -4402,12 +4330,10 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -4877,9 +4803,10 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -6265,12 +6192,10 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -8392,12 +8317,10 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -8510,9 +8433,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"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,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
@@ -9514,11 +9438,12 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.2",
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -10760,9 +10685,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
@@ -11524,9 +11450,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"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,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
@@ -12147,14 +12074,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"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": {
|
||||
"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,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -12917,10 +12834,10 @@
|
||||
},
|
||||
"workflow-parser": {
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.23",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.20",
|
||||
"@actions/expressions": "^0.3.23",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "actions-languageservices",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"./expressions",
|
||||
"./workflow-parser",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# Workflow Schema Optimization Plan
|
||||
|
||||
## Current State (Commit 7660f61)
|
||||
|
||||
### What's Implemented
|
||||
|
||||
1. **Original schema preserved**: `workflow-v1.0.json` remains source of truth with 291 definitions
|
||||
2. **Optimization script**: `script/optimize-workflow-schema.js` prunes unused definitions
|
||||
3. **Generated files** (gitignored):
|
||||
- `workflow-v1.0.optimized.json` - 281 definitions (pruned)
|
||||
- `workflow-v1.0.optimized.min.json` - minified version loaded at runtime
|
||||
4. **Build pipeline**: `npm run minify-json` chains optimize → minify
|
||||
5. **Tests**: `workflow-schema.test.ts` validates schema integrity
|
||||
|
||||
### 10 Pruned Definitions
|
||||
|
||||
These are unreachable from `workflow-root-strict` entry point:
|
||||
|
||||
- `workflow-root` (non-strict variant)
|
||||
- `on` (non-strict variant)
|
||||
- `on-mapping` (non-strict variant)
|
||||
- `job-if-result`
|
||||
- `step-if-result`
|
||||
- `boolean-needs-context`
|
||||
- `number-needs-context`
|
||||
- `string-needs-context`
|
||||
- `boolean-steps-context`
|
||||
- `number-steps-context`
|
||||
|
||||
### Size Savings
|
||||
|
||||
| Metric | Original | Optimized | Savings |
|
||||
|--------|----------|-----------|---------|
|
||||
| Definitions | 291 | 281 | 10 removed |
|
||||
| Minified | 71,061 B | 69,022 B | 2.9% |
|
||||
| Gzipped | 12,318 B | 12,172 B | 1.2% |
|
||||
|
||||
## Optimization Strategies Evaluated
|
||||
|
||||
### ✅ Pruning Unused Definitions (IMPLEMENTED)
|
||||
- Removes definitions not reachable from entry point
|
||||
- 1.2% gzip savings
|
||||
- Low complexity, no runtime overhead
|
||||
|
||||
### ❌ Key Shortening
|
||||
- Replace long keys with short codes (e.g., `description` → `d`)
|
||||
- 1.5% gzip savings
|
||||
- NOT WORTH IT: Adds complexity, minimal benefit after gzip
|
||||
|
||||
### ❌ String Interning
|
||||
- Deduplicate repeated strings into lookup table
|
||||
- Makes gzip WORSE (-0.5%)
|
||||
- NOT WORTH IT: Gzip already handles repetition
|
||||
|
||||
### ❌ Compact Format (like webhooks)
|
||||
- Restructure to array-based format
|
||||
- Makes gzip WORSE (-0.4%)
|
||||
- NOT WORTH IT: Schema structure doesn't benefit
|
||||
|
||||
### ❌ Split Descriptions
|
||||
- Separate file for descriptions
|
||||
- Adds 791 bytes when both files gzipped
|
||||
- NOT WORTH IT: Worse total size
|
||||
|
||||
## Future Work
|
||||
|
||||
### Update from Server
|
||||
The current schema may be missing some definitions from the latest server version. A future PR should:
|
||||
1. Fetch latest `workflow-v1.0.json` from dotcom server
|
||||
2. Update the source file
|
||||
3. Verify tests still pass
|
||||
4. Note: Server version had issues last checked (e.g., `coerce-raw`, missing `branches` on merge-group)
|
||||
|
||||
### Entry Point
|
||||
- Entry point: `workflow-root-strict` (defined in `workflow-constants.ts`)
|
||||
- Non-strict `workflow-root` exists but is unused in this codebase
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
workflow-parser/
|
||||
├── src/
|
||||
│ ├── workflow-v1.0.json # Source of truth (tracked, 291 defs)
|
||||
│ ├── workflow-v1.0.optimized.json # Pruned (gitignored, 281 defs)
|
||||
│ ├── workflow-v1.0.optimized.min.json # Minified (gitignored)
|
||||
│ └── workflows/
|
||||
│ ├── workflow-schema.ts # Loader (imports optimized.min.json)
|
||||
│ └── workflow-schema.test.ts # Schema integrity tests
|
||||
└── script/
|
||||
└── optimize-workflow-schema.js # Pruning script
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- `workflow-schema.test.ts`:
|
||||
1. Schema loads from workflow-root-strict
|
||||
2. All referenced definitions are reachable
|
||||
3. Critical definitions exist (jobs, steps, runs-on, etc.)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.23",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -38,19 +38,23 @@
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"optimize-schema": "node script/optimize-workflow-schema.js",
|
||||
"minify-json": "npm run optimize-schema && node ../script/minify-json.js src/workflow-v1.0.optimized.json",
|
||||
"prebuild": "npm run minify-json",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"pretest": "npm run minify-json",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
"test-xlang": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathPattern xlang",
|
||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.20",
|
||||
"@actions/expressions": "^0.3.23",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Optimizes workflow-v1.0.json by pruning unused definitions.
|
||||
*
|
||||
* Removes definitions not reachable from the entry point (workflow-root-strict).
|
||||
* Output is then minified by minify-json.js to produce the final .min.json file.
|
||||
*
|
||||
* Usage: node script/optimize-workflow-schema.js
|
||||
*/
|
||||
|
||||
import {promises as fs} from "fs";
|
||||
import path from "path";
|
||||
import {fileURLToPath} from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ENTRY_POINT = "workflow-root-strict";
|
||||
|
||||
const inputPath = path.join(__dirname, "..", "src", "workflow-v1.0.json");
|
||||
const outputPath = path.join(__dirname, "..", "src", "workflow-v1.0.optimized.json");
|
||||
|
||||
/**
|
||||
* Find all type references in a definition.
|
||||
*/
|
||||
function findRefs(obj) {
|
||||
const refs = [];
|
||||
|
||||
function visit(node) {
|
||||
if (!node || typeof node !== "object") return;
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
if (typeof item === "string") refs.push(item);
|
||||
else visit(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (["type", "item-type", "loose-key-type", "loose-value-type"].includes(key)) {
|
||||
if (typeof value === "string") refs.push(value);
|
||||
} else if (key === "one-of") {
|
||||
visit(value);
|
||||
} else if (key === "properties") {
|
||||
for (const propValue of Object.values(value)) {
|
||||
if (typeof propValue === "string") refs.push(propValue);
|
||||
else if (propValue && typeof propValue === "object") visit(propValue);
|
||||
}
|
||||
} else if (["mapping", "sequence", "string"].includes(key)) {
|
||||
visit(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(obj);
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all definitions reachable from entry point.
|
||||
*/
|
||||
function findReachable(schema, entryPoint) {
|
||||
const reachable = new Set();
|
||||
const queue = [entryPoint];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const name = queue.shift();
|
||||
if (reachable.has(name) || !schema.definitions[name]) continue;
|
||||
reachable.add(name);
|
||||
|
||||
const refs = findRefs(schema.definitions[name]);
|
||||
for (const ref of refs) {
|
||||
if (!reachable.has(ref) && schema.definitions[ref]) {
|
||||
queue.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reachable;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const content = await fs.readFile(inputPath, "utf8");
|
||||
const schema = JSON.parse(content);
|
||||
|
||||
const reachable = findReachable(schema, ENTRY_POINT);
|
||||
const allDefs = Object.keys(schema.definitions);
|
||||
const unused = allDefs.filter((name) => !reachable.has(name));
|
||||
|
||||
console.log(`Entry point: ${ENTRY_POINT}`);
|
||||
console.log(`Definitions: ${allDefs.length} -> ${reachable.size} (${unused.length} pruned)`);
|
||||
|
||||
if (unused.length > 0) {
|
||||
console.log("\nPruned:");
|
||||
unused.forEach((name) => console.log(` - ${name}`));
|
||||
}
|
||||
|
||||
// Create pruned schema preserving definition order
|
||||
const pruned = {version: schema.version, definitions: {}};
|
||||
for (const name of allDefs) {
|
||||
if (reachable.has(name)) {
|
||||
pruned.definitions[name] = schema.definitions[name];
|
||||
}
|
||||
}
|
||||
|
||||
// Write output (will be minified by minify-json.js)
|
||||
await fs.writeFile(outputPath, JSON.stringify(pruned));
|
||||
|
||||
console.log(`\nOutput: ${outputPath}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import {isValidCron, getCronDescription} from "./cron";
|
||||
import {isValidCron, getCronDescription, hasCronIntervalLessThan5Minutes} from "./cron";
|
||||
|
||||
describe("cron", () => {
|
||||
describe("valid cron", () => {
|
||||
@@ -66,14 +66,54 @@ describe("cron", () => {
|
||||
|
||||
describe("getCronDescription", () => {
|
||||
it(`Produces a sentence for valid cron`, () => {
|
||||
expect(getCronDescription("0 * * * *")).toEqual(
|
||||
"Runs every hour\n\n" +
|
||||
"Actions schedules run at most every 5 minutes. [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
|
||||
);
|
||||
expect(getCronDescription("0 * * * *")).toEqual("Runs every hour");
|
||||
});
|
||||
|
||||
it(`Returns nothing for invalid cron`, () => {
|
||||
expect(getCronDescription("* * * * * *")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasCronIntervalLessThan5Minutes", () => {
|
||||
it("returns true for step expressions with interval < 5 min", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("*/1 * * * *")).toBe(true);
|
||||
expect(hasCronIntervalLessThan5Minutes("*/4 * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for step expressions with interval >= 5 min", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("*/5 * * * *")).toBe(false);
|
||||
expect(hasCronIntervalLessThan5Minutes("*/15 * * * *")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for comma-separated values with gap < 5 min", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("0,2,4 * * * *")).toBe(true);
|
||||
expect(hasCronIntervalLessThan5Minutes("0,10,12 * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for comma-separated values with gap >= 5 min", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("0,10,20 * * * *")).toBe(false);
|
||||
expect(hasCronIntervalLessThan5Minutes("0,30 * * * *")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for comma-separated values with wrap-around gap < 5 min", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("0,58 * * * *")).toBe(true);
|
||||
expect(hasCronIntervalLessThan5Minutes("2,59 * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for * (every minute)", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("* * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for range expressions (runs every minute in range)", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("0-4 * * * *")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for single value (hourly)", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("0 * * * *")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for invalid cron", () => {
|
||||
expect(hasCronIntervalLessThan5Minutes("invalid")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,78 @@ type Range = {
|
||||
names?: Record<string, number>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a cron expression specifies an interval shorter than 5 minutes.
|
||||
* GitHub Actions schedules run at most every 5 minutes, so intervals < 5 min won't work as expected.
|
||||
*/
|
||||
export function hasCronIntervalLessThan5Minutes(cron: string): boolean {
|
||||
if (!isValidCron(cron)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = cron.split(/ +/);
|
||||
const minutePart = parts[0];
|
||||
|
||||
// Parse the minute field to determine the effective interval
|
||||
return getMinuteInterval(minutePart) < 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the minimum interval in minutes between cron executions based on the minute field.
|
||||
* Returns 60 if there's only one execution per hour, otherwise returns the minimum gap.
|
||||
*/
|
||||
function getMinuteInterval(minutePart: string): number {
|
||||
// Handle step expressions like */1, */3, 0-59/2
|
||||
if (minutePart.includes("/")) {
|
||||
const [, step] = minutePart.split("/");
|
||||
const stepNum = parseInt(step, 10);
|
||||
if (!isNaN(stepNum) && stepNum > 0) {
|
||||
return stepNum;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle comma-separated values like 0,2,4 or 0,1,5,10
|
||||
if (minutePart.includes(",")) {
|
||||
const values = minutePart
|
||||
.split(",")
|
||||
.map(v => parseInt(v, 10))
|
||||
.filter(n => !isNaN(n))
|
||||
.sort((a, b) => a - b);
|
||||
if (values.length >= 2) {
|
||||
let minGap = 60;
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
const gap = values[i] - values[i - 1];
|
||||
if (gap < minGap) {
|
||||
minGap = gap;
|
||||
}
|
||||
}
|
||||
// Check wrap-around gap from last minute to first minute of next hour
|
||||
const wrapGap = values[0] + 60 - values[values.length - 1];
|
||||
if (wrapGap < minGap) {
|
||||
minGap = wrapGap;
|
||||
}
|
||||
return minGap;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle range expressions like 0-4 (runs every minute from 0-4)
|
||||
if (minutePart.includes("-") && !minutePart.includes("/")) {
|
||||
const [start, end] = minutePart.split("-").map(v => parseInt(v, 10));
|
||||
if (!isNaN(start) && !isNaN(end) && end > start) {
|
||||
// A range without step means every minute in that range
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// * means every minute
|
||||
if (minutePart === "*") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Single value or unrecognized pattern - assume hourly (60 min interval)
|
||||
return 60;
|
||||
}
|
||||
|
||||
export function isValidCron(cron: string): boolean {
|
||||
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
||||
|
||||
@@ -46,11 +118,7 @@ export function getCronDescription(cronspec: string): string | undefined {
|
||||
}
|
||||
|
||||
// Make first character lowercase
|
||||
let result = "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
|
||||
result +=
|
||||
"\n\nActions schedules run at most every 5 minutes." +
|
||||
" [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)";
|
||||
return result;
|
||||
return "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
|
||||
}
|
||||
|
||||
function validateCronPart(value: string, range: Range, allowSeparators = true): boolean {
|
||||
|
||||
@@ -158,7 +158,7 @@ function convertSchedule(context: TemplateContext, token: SequenceToken): Schedu
|
||||
const cron = schedule.value.assertString(`schedule cron`);
|
||||
// Validate the cron string
|
||||
if (!isValidCron(cron.value)) {
|
||||
context.error(cron, "Invalid cron string");
|
||||
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
|
||||
}
|
||||
result.push({cron: cron.value});
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {getWorkflowSchema} from "./workflow-schema";
|
||||
|
||||
describe("workflow-schema", () => {
|
||||
it("loads successfully from workflow-root-strict entry point", () => {
|
||||
const schema = getWorkflowSchema();
|
||||
expect(schema).toBeDefined();
|
||||
|
||||
// Verify entry point exists
|
||||
const rootDef = schema.getDefinition("workflow-root-strict");
|
||||
expect(rootDef).toBeDefined();
|
||||
});
|
||||
|
||||
it("has all referenced definitions reachable from workflow-root-strict", () => {
|
||||
const schema = getWorkflowSchema();
|
||||
const definitions = schema.definitions;
|
||||
|
||||
// Collect all type references from all definitions
|
||||
const referencedTypes = new Set<string>();
|
||||
const definedTypes = new Set<string>();
|
||||
|
||||
for (const name of Object.keys(definitions)) {
|
||||
definedTypes.add(name);
|
||||
collectReferences(definitions[name], referencedTypes);
|
||||
}
|
||||
|
||||
// Every referenced type should be defined
|
||||
const missingDefinitions: string[] = [];
|
||||
for (const ref of referencedTypes) {
|
||||
// Skip built-in types
|
||||
if (isBuiltInType(ref)) continue;
|
||||
|
||||
if (!definedTypes.has(ref)) {
|
||||
missingDefinitions.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingDefinitions).toEqual([]);
|
||||
});
|
||||
|
||||
it("can resolve key workflow definitions", () => {
|
||||
const schema = getWorkflowSchema();
|
||||
|
||||
// These are critical definitions that must exist
|
||||
const criticalDefinitions = [
|
||||
"workflow-root-strict",
|
||||
"jobs",
|
||||
"steps",
|
||||
"runs-on",
|
||||
"step-uses",
|
||||
"job-env",
|
||||
"step-env",
|
||||
];
|
||||
|
||||
for (const defName of criticalDefinitions) {
|
||||
const def = schema.getDefinition(defName);
|
||||
expect(def).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function collectReferences(obj: unknown, refs: Set<string>): void {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
if (typeof item === "string") refs.add(item);
|
||||
else collectReferences(item, refs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (["type", "item-type", "loose-key-type", "loose-value-type"].includes(key)) {
|
||||
if (typeof value === "string") refs.add(value);
|
||||
} else if (key === "one-of" || key === "properties" || key === "mapping" || key === "sequence") {
|
||||
collectReferences(value, refs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBuiltInType(typeName: string): boolean {
|
||||
const builtIns = ["null", "boolean", "number", "string", "sequence", "mapping", "any"];
|
||||
return builtIns.includes(typeName);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {JSONObjectReader} from "../templates/json-object-reader";
|
||||
import {TemplateSchema} from "../templates/schema";
|
||||
import WorkflowSchema from "../workflow-v1.0.json";
|
||||
import WorkflowSchema from "../workflow-v1.0.optimized.min.json";
|
||||
|
||||
let schema: TemplateSchema;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user