Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5abd234cbf | |||
| 742b36d6b7 | |||
| 8507419ebf | |||
| 952dc89b78 | |||
| 2934e36944 | |||
| 8d2c24d7f5 | |||
| 4181cb3c90 | |||
| 78ea3ba17f | |||
| 4cf3365c68 | |||
| 1a63ee9de6 | |||
| 108b8c2766 | |||
| e20dbae803 | |||
| 69b383af3d | |||
| 4429c41275 | |||
| 7b9adb106e | |||
| 576402fc01 | |||
| 22c36bc946 | |||
| 4dd678cf30 | |||
| dfb411f71e | |||
| dec597b0db | |||
| bd7e5f0b70 | |||
| 37ba6ab105 | |||
| 216fcbb8c4 | |||
| 751cb5a940 |
@@ -12,18 +12,55 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [18.x, 20.x, 22.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 16.15
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 16.15
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
- run: npm ci
|
- run: npm ci --engine-strict
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- run: npm run format-check -ws
|
- run: npm run format-check -ws
|
||||||
- run: npm run build -ws
|
- run: npm run build -ws
|
||||||
- run: npm run lint -ws
|
- run: npm run lint -ws
|
||||||
- run: npm test -ws
|
- run: npm test -ws
|
||||||
|
|
||||||
|
check-generated:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 22.x
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.x
|
||||||
|
cache: 'npm'
|
||||||
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
|
- run: npm ci
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Regenerate JSON files
|
||||||
|
run: |
|
||||||
|
cd languageservice && npm run update-webhooks && cd ..
|
||||||
|
- name: Check for uncommitted changes
|
||||||
|
run: |
|
||||||
|
if ! git diff --exit-code; then
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "ERROR: Generated files are out of date!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "Please run the following commands locally and commit the changes:"
|
||||||
|
echo ""
|
||||||
|
echo " cd languageservice && npm run update-webhooks && cd .."
|
||||||
|
echo " git add -A && git commit -m 'Regenerate JSON files'"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 16.x
|
node-version: 22.x
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
scope: '@actions'
|
scope: '@actions'
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -2,4 +2,12 @@
|
|||||||
*/dist
|
*/dist
|
||||||
lerna-debug.log
|
lerna-debug.log
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Minified JSON (generated at build time)
|
||||||
|
*.min.json
|
||||||
|
|
||||||
|
# Intermediate JSON for size comparison (generated by update-webhooks --all)
|
||||||
|
*.all.json
|
||||||
|
*.drop.json
|
||||||
|
*.strip.json
|
||||||
@@ -8,6 +8,10 @@ This repository contains multiple npm packages for working with GitHub Actions w
|
|||||||
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
|
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
|
||||||
- [browser-playground](./browser-playground) - Browser-based playground for the language service
|
- [browser-playground](./browser-playground) - Browser-based playground for the language service
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
|
||||||
|
|
||||||
### Note
|
### Note
|
||||||
|
|
||||||
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
|
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# JSON Data Files
|
||||||
|
|
||||||
|
This document describes the JSON data files used by the language service packages and how they are maintained.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The language service uses several JSON files containing schema definitions, webhook payloads, and other metadata. To reduce bundle size, these files are:
|
||||||
|
|
||||||
|
1. **Optimized at generation time** — unused events are dropped, unused fields are stripped
|
||||||
|
2. **Minified at build time** — whitespace is removed to produce `.min.json` files
|
||||||
|
|
||||||
|
The source `.json` files are human-readable and checked into the repository. The `.min.json` files are generated during build and gitignored.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### languageservice
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `src/context-providers/events/webhooks.json` | Webhook event payload schemas for autocompletion |
|
||||||
|
| `src/context-providers/events/objects.json` | Deduplicated shared object definitions referenced by webhooks |
|
||||||
|
| `src/context-providers/events/schedule.json` | Schedule event context data |
|
||||||
|
| `src/context-providers/events/workflow_call.json` | Reusable workflow call context data |
|
||||||
|
| `src/context-providers/descriptions.json` | Context variable descriptions for hover |
|
||||||
|
|
||||||
|
### workflow-parser
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `src/workflow-v1.0.json` | Workflow YAML schema definition |
|
||||||
|
|
||||||
|
## Generation
|
||||||
|
|
||||||
|
### Webhooks and Objects
|
||||||
|
|
||||||
|
The `webhooks.json` and `objects.json` files are generated from the [GitHub REST API description](https://github.com/github/rest-api-description):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd languageservice
|
||||||
|
npm run update-webhooks
|
||||||
|
```
|
||||||
|
|
||||||
|
This script:
|
||||||
|
1. Fetches webhook schemas from the GitHub API description
|
||||||
|
2. **Validates** all events are categorized (fails if new events are found)
|
||||||
|
3. **Drops** events that aren't valid workflow triggers (see [Dropped Events](#dropped-events))
|
||||||
|
4. **Strips** unused fields like `description` and `summary` (see [Stripped Fields](#stripped-fields))
|
||||||
|
5. **Deduplicates** shared object definitions into `objects.json`
|
||||||
|
6. Writes the optimized, pretty-printed JSON files
|
||||||
|
|
||||||
|
### Handling New Webhook Events
|
||||||
|
|
||||||
|
When GitHub adds a new webhook event, the script will fail with an error like:
|
||||||
|
|
||||||
|
```
|
||||||
|
ERROR: New webhook event(s) detected!
|
||||||
|
|
||||||
|
The following events are not categorized:
|
||||||
|
- new_event_name
|
||||||
|
|
||||||
|
Action required:
|
||||||
|
1. Check if the event is a valid workflow trigger
|
||||||
|
2. Add the event to DROPPED_EVENTS or KEPT_EVENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
**To resolve:**
|
||||||
|
|
||||||
|
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
|
||||||
|
|
||||||
|
2. Edit `languageservice/script/webhooks/index.ts`:
|
||||||
|
- Add to `KEPT_EVENTS` if it's a valid workflow trigger
|
||||||
|
- Add to `DROPPED_EVENTS` if it's GitHub App or API-only
|
||||||
|
|
||||||
|
3. Run `npm run update-webhooks` and commit the changes
|
||||||
|
|
||||||
|
#### Viewing Full Unprocessed Data
|
||||||
|
|
||||||
|
To see all available fields and events before optimization:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run update-webhooks -- --all
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates `webhooks.all.json` and `objects.all.json` (gitignored) containing the complete unprocessed data from the GitHub API.
|
||||||
|
|
||||||
|
### Other Files
|
||||||
|
|
||||||
|
The other JSON files (`schedule.json`, `workflow_call.json`, `descriptions.json`, `workflow-v1.0.json`) are manually maintained.
|
||||||
|
|
||||||
|
## Minification
|
||||||
|
|
||||||
|
At build time, all JSON files are minified (whitespace removed) to produce `.min.json` versions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run minify-json
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs automatically via `prebuild` and `pretest` hooks, so you don't need to run it manually.
|
||||||
|
|
||||||
|
The code imports the minified versions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import webhooks from "./events/webhooks.min.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI Verification
|
||||||
|
|
||||||
|
CI verifies that generated source files are up-to-date:
|
||||||
|
|
||||||
|
1. Runs `npm run update-webhooks` to regenerate webhooks.json and objects.json
|
||||||
|
2. Checks for uncommitted changes with `git diff --exit-code`
|
||||||
|
|
||||||
|
The `.min.json` files are generated at build time and are not committed to the repository.
|
||||||
|
|
||||||
|
If the build fails, run `cd languageservice && npm run update-webhooks` locally and commit the changes.
|
||||||
|
|
||||||
|
## Dropped Events
|
||||||
|
|
||||||
|
Webhook events that aren't valid workflow `on:` triggers are dropped (e.g., `installation`, `ping`, `member`, etc.). These are GitHub App or API-only events.
|
||||||
|
|
||||||
|
See `DROPPED_EVENTS` in `script/webhooks/index.ts` for the full list.
|
||||||
|
|
||||||
|
## Stripped Fields
|
||||||
|
|
||||||
|
Unused fields are stripped to reduce bundle size. For example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Before (from webhooks.all.json)
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"name": "issue",
|
||||||
|
"in": "body",
|
||||||
|
"description": "The issue itself.",
|
||||||
|
"isRequired": true,
|
||||||
|
"childParamsGroups": [...]
|
||||||
|
}
|
||||||
|
|
||||||
|
// After (webhooks.json)
|
||||||
|
{
|
||||||
|
"name": "issue",
|
||||||
|
"description": "The issue itself.",
|
||||||
|
"childParamsGroups": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `name`, `description`, and `childParamsGroups` are kept — these are used for autocompletion and hover docs.
|
||||||
|
|
||||||
|
To compare all fields vs stripped, run `npm run update-webhooks -- --all` and diff the `.all.json` files against the regular ones.
|
||||||
|
|
||||||
|
See `EVENT_ACTION_FIELDS` and `BODY_PARAM_FIELDS` in `script/webhooks/index.ts` to modify what gets stripped.
|
||||||
|
|
||||||
|
## Schema Synchronization
|
||||||
|
|
||||||
|
The `workflow-v1.0.json` schema defines which activity types are valid for each workflow trigger event. A test in `workflow-parser/src/schema-sync.test.ts` verifies these stay in sync with `webhooks.json`.
|
||||||
|
|
||||||
|
### When the Test Fails
|
||||||
|
|
||||||
|
If the schema-sync test fails, you'll see an error like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Event "pull_request" is missing activity type "new_activity" in workflow-v1.0.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**To resolve:**
|
||||||
|
|
||||||
|
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows) to verify the activity type is a valid workflow trigger:
|
||||||
|
- Find the event section (e.g., "pull_request")
|
||||||
|
- Look at the "Activity types" table — it lists which types can be used in `on.<event>.types`
|
||||||
|
- If the type is listed there, it's a valid workflow trigger
|
||||||
|
- If the type only appears in webhook docs but NOT in the workflow trigger docs, it's webhook-only
|
||||||
|
|
||||||
|
2. If it IS a valid workflow trigger:
|
||||||
|
- Edit `workflow-parser/src/workflow-v1.0.json`
|
||||||
|
- Find the `<event>-activity-type` definition (e.g., `pull-request-activity-type`)
|
||||||
|
- Add the new activity type to `allowed-values`
|
||||||
|
- Update the `description` in `<event>-activity` to list all types
|
||||||
|
- Run `npm test` to regenerate the minified JSON
|
||||||
|
|
||||||
|
3. If it is NOT a valid workflow trigger (webhook-only):
|
||||||
|
- Edit `workflow-parser/src/schema-sync.test.ts`
|
||||||
|
- Add the type to `WEBHOOK_ONLY` for that event
|
||||||
|
|
||||||
|
### Known Discrepancies
|
||||||
|
|
||||||
|
The test tracks several types of known discrepancies:
|
||||||
|
|
||||||
|
| Category | Purpose | Example |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `WEBHOOK_ONLY` | Types in webhooks that aren't valid workflow triggers | `check_suite.requested` |
|
||||||
|
| `SCHEMA_ONLY` | Types valid for workflows but missing from webhooks | `registry_package.updated` |
|
||||||
|
| `NAME_MAPPINGS` | Different names for the same concept | `project_column`: webhook uses `edited`, schema uses `updated` |
|
||||||
|
|
||||||
|
### Bidirectional Checking
|
||||||
|
|
||||||
|
The test checks both directions:
|
||||||
|
- **webhooks → schema**: Ensures all webhook activity types are in the schema (or listed in `WEBHOOK_ONLY`)
|
||||||
|
- **schema → webhooks**: Ensures the schema doesn't have types that don't exist in webhooks (or listed in `SCHEMA_ONLY` or `NAME_MAPPINGS`)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/expressions",
|
"name": "@actions/expressions",
|
||||||
"version": "0.3.20",
|
"version": "0.3.25",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16.15"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageserver",
|
"name": "@actions/languageserver",
|
||||||
"version": "0.3.20",
|
"version": "0.3.25",
|
||||||
"description": "Language server for GitHub Actions",
|
"description": "Language server for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/languageservice": "^0.3.20",
|
"@actions/languageservice": "^0.3.25",
|
||||||
"@actions/workflow-parser": "^0.3.20",
|
"@actions/workflow-parser": "^0.3.25",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
"@octokit/types": "^9.0.0",
|
"@octokit/types": "^9.0.0",
|
||||||
"vscode-languageserver": "^8.0.2",
|
"vscode-languageserver": "^8.0.2",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"yaml": "^2.1.3"
|
"yaml": "^2.1.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16.15"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
|
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||||
|
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||||
|
import {contextProviders} from "./context-providers";
|
||||||
|
import {RepositoryContext} from "./initializationOptions";
|
||||||
|
import {TTLCache} from "./utils/cache";
|
||||||
|
|
||||||
|
describe("contextProviders", () => {
|
||||||
|
const mockCache = new TTLCache();
|
||||||
|
const mockRepo: RepositoryContext = {
|
||||||
|
id: 123,
|
||||||
|
owner: "test-owner",
|
||||||
|
name: "test-repo",
|
||||||
|
organizationOwned: true,
|
||||||
|
workspaceUri: "file:///workspace"
|
||||||
|
};
|
||||||
|
const mockWorkflowContext: WorkflowContext = {
|
||||||
|
uri: "test.yaml",
|
||||||
|
template: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("when client is undefined", () => {
|
||||||
|
it("should return incomplete context for secrets", async () => {
|
||||||
|
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||||
|
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||||
|
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return incomplete context for vars", async () => {
|
||||||
|
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||||
|
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||||
|
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve defaultContext and mark as incomplete for secrets", async () => {
|
||||||
|
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||||
|
const defaultContext = new DescriptionDictionary();
|
||||||
|
defaultContext.add("EXISTING_SECRET", new data.StringData("test"));
|
||||||
|
|
||||||
|
const result = await config.getContext("secrets", defaultContext, mockWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
expect(result).toBe(defaultContext);
|
||||||
|
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||||
|
expect((result as DescriptionDictionary).get("EXISTING_SECRET")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined for other contexts like steps", async () => {
|
||||||
|
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||||
|
const result = await config.getContext("steps", undefined, mockWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when both client and repo are undefined", () => {
|
||||||
|
it("should return incomplete context for secrets", async () => {
|
||||||
|
const config = contextProviders(undefined, undefined, mockCache);
|
||||||
|
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||||
|
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return incomplete context for vars", async () => {
|
||||||
|
const config = contextProviders(undefined, undefined, mockCache);
|
||||||
|
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||||
|
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,18 @@ export function contextProviders(
|
|||||||
cache: TTLCache
|
cache: TTLCache
|
||||||
): ContextProviderConfig {
|
): ContextProviderConfig {
|
||||||
if (!repo || !client) {
|
if (!repo || !client) {
|
||||||
return {getContext: () => Promise.resolve(undefined)};
|
// When GitHub client/repo is unavailable, return an incomplete dictionary
|
||||||
|
// to avoid false "Context access might be invalid" warnings
|
||||||
|
return {
|
||||||
|
getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => {
|
||||||
|
if (name === "secrets" || name === "vars") {
|
||||||
|
const context = defaultContext || new DescriptionDictionary();
|
||||||
|
context.complete = false;
|
||||||
|
return Promise.resolve(context);
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getContext = async (
|
const getContext = async (
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export async function getSecrets(
|
|||||||
if (isString(x.value)) {
|
if (isString(x.value)) {
|
||||||
environmentName = x.value.value;
|
environmentName = x.value.value;
|
||||||
} else {
|
} else {
|
||||||
// this means we have a dynamic enviornment, in those situations we
|
// this means we have a dynamic environment, in those situations we
|
||||||
// want to make sure we skip doing secret validation
|
// want to make sure we skip doing secret validation
|
||||||
secretsContext.complete = false;
|
secretsContext.complete = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||||
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
|
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
@@ -63,6 +63,43 @@ it("returns default context when job is undefined", async () => {
|
|||||||
expect(stepsContext).toEqual(defaultContext);
|
expect(stepsContext).toEqual(defaultContext);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
|
||||||
|
const mock = fetchMock
|
||||||
|
.sandbox()
|
||||||
|
.getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata);
|
||||||
|
|
||||||
|
const workflowContext = await createWorkflowContext(workflow, "build");
|
||||||
|
const defaultContext = getDefaultStepsContext(workflowContext);
|
||||||
|
|
||||||
|
const stepsContext = await getStepsContext(
|
||||||
|
new Octokit({
|
||||||
|
request: {
|
||||||
|
fetch: mock
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
new TTLCache(),
|
||||||
|
defaultContext,
|
||||||
|
workflowContext
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the step context
|
||||||
|
const stepContext = stepsContext?.get("cache-primes");
|
||||||
|
expect(stepContext).toBeDefined();
|
||||||
|
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
||||||
|
|
||||||
|
// Get the outputs - should be a dictionary, not null
|
||||||
|
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||||
|
expect(outputs).toBeDefined();
|
||||||
|
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
||||||
|
|
||||||
|
// Outputs should be marked incomplete to allow dynamic outputs
|
||||||
|
const outputsDict = outputs as DescriptionDictionary;
|
||||||
|
expect(outputsDict.complete).toBe(false);
|
||||||
|
|
||||||
|
// Known outputs from action.yml should be present
|
||||||
|
expect(outputsDict.get("cache-hit")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("adds action outputs", async () => {
|
it("adds action outputs", async () => {
|
||||||
const mock = fetchMock
|
const mock = fetchMock
|
||||||
.sandbox()
|
.sandbox()
|
||||||
@@ -83,17 +120,22 @@ it("adds action outputs", async () => {
|
|||||||
);
|
);
|
||||||
expect(stepsContext).toBeDefined();
|
expect(stepsContext).toBeDefined();
|
||||||
|
|
||||||
|
// Create expected outputs dict with complete = false
|
||||||
|
// (actions can have dynamic outputs beyond what's declared in action.yml)
|
||||||
|
const expectedOutputs = new DescriptionDictionary({
|
||||||
|
key: "cache-hit",
|
||||||
|
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
|
||||||
|
description: "A boolean value to indicate an exact match was found for the primary key"
|
||||||
|
});
|
||||||
|
expectedOutputs.complete = false;
|
||||||
|
|
||||||
expect(stepsContext).toEqual(
|
expect(stepsContext).toEqual(
|
||||||
new DescriptionDictionary({
|
new DescriptionDictionary({
|
||||||
key: "cache-primes",
|
key: "cache-primes",
|
||||||
value: new DescriptionDictionary(
|
value: new DescriptionDictionary(
|
||||||
{
|
{
|
||||||
key: "outputs",
|
key: "outputs",
|
||||||
value: new DescriptionDictionary({
|
value: expectedOutputs
|
||||||
key: "cache-hit",
|
|
||||||
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
|
|
||||||
description: "A boolean value to indicate an exact match was found for the primary key"
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "conclusion",
|
key: "conclusion",
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export async function getStepsContext(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const outputsDict = new DescriptionDictionary();
|
const outputsDict = new DescriptionDictionary();
|
||||||
|
// Actions can have dynamic outputs beyond what's declared in action.yml
|
||||||
|
outputsDict.complete = false;
|
||||||
for (const [key, value] of Object.entries(outputs)) {
|
for (const [key, value] of Object.entries(outputs)) {
|
||||||
outputsDict.add(key, new data.StringData(value.description), value.description);
|
outputsDict.add(key, new data.StringData(value.description), value.description);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export async function getVariables(
|
|||||||
return secretsContext;
|
return secretsContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||||
|
|
||||||
let environmentName: string | undefined;
|
let environmentName: string | undefined;
|
||||||
if (workflowContext?.job?.environment) {
|
if (workflowContext?.job?.environment) {
|
||||||
if (isString(workflowContext.job.environment)) {
|
if (isString(workflowContext.job.environment)) {
|
||||||
@@ -35,14 +37,19 @@ export async function getVariables(
|
|||||||
if (isString(x.key) && x.key.value === "name") {
|
if (isString(x.key) && x.key.value === "name") {
|
||||||
if (isString(x.value)) {
|
if (isString(x.value)) {
|
||||||
environmentName = x.value.value;
|
environmentName = x.value.value;
|
||||||
|
} else {
|
||||||
|
// this means we have a dynamic environment, in those situations we want to skip validation
|
||||||
|
variablesContext.complete = false;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// if the expression is something like environment: ${{ ... }} then we want to skip validation
|
||||||
|
variablesContext.complete = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
|
||||||
try {
|
try {
|
||||||
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
|
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ inputs:
|
|||||||
description: 'Repository name with owner. For example, actions/checkout'
|
description: 'Repository name with owner. For example, actions/checkout'
|
||||||
deprecationMessage: 'Use repository instead'
|
deprecationMessage: 'Use repository instead'
|
||||||
runs:
|
runs:
|
||||||
using: node16
|
using: node24
|
||||||
main: dist/index.js
|
main: dist/index.js
|
||||||
post: dist/index.js
|
post: dist/index.js
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ inputs:
|
|||||||
description: Repository name with owner. For example, actions/checkout
|
description: Repository name with owner. For example, actions/checkout
|
||||||
default: \${{ github.repository }}
|
default: \${{ github.repository }}
|
||||||
runs:
|
runs:
|
||||||
using: node16
|
using: node24
|
||||||
main: dist/index.js
|
main: dist/index.js
|
||||||
post: dist/index.js
|
post: dist/index.js
|
||||||
`;
|
`;
|
||||||
@@ -231,7 +231,7 @@ inputs:
|
|||||||
description: 📦 Repository 📦 name with owner. For example, actions/checkout
|
description: 📦 Repository 📦 name with owner. For example, actions/checkout
|
||||||
default: \${{ github.repository }}
|
default: \${{ github.repository }}
|
||||||
runs:
|
runs:
|
||||||
using: node16
|
using: node24
|
||||||
main: dist/index.js
|
main: dist/index.js
|
||||||
post: dist/index.js
|
post: dist/index.js
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageservice",
|
"name": "@actions/languageservice",
|
||||||
"version": "0.3.20",
|
"version": "0.3.25",
|
||||||
"description": "Language service for GitHub Actions",
|
"description": "Language service for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -37,22 +37,25 @@
|
|||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||||
|
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
|
||||||
|
"prebuild": "npm run minify-json",
|
||||||
"prepublishOnly": "npm run build && npm run test",
|
"prepublishOnly": "npm run build && npm run test",
|
||||||
|
"pretest": "npm run minify-json",
|
||||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||||
"update-webhooks": "ts-node-esm script/webhooks/index.ts",
|
"update-webhooks": "npx tsx script/webhooks/index.ts",
|
||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.20",
|
"@actions/expressions": "^0.3.25",
|
||||||
"@actions/workflow-parser": "^0.3.20",
|
"@actions/workflow-parser": "^0.3.25",
|
||||||
"vscode-languageserver-textdocument": "^1.0.7",
|
"vscode-languageserver-textdocument": "^1.0.7",
|
||||||
"vscode-languageserver-types": "^3.17.2",
|
"vscode-languageserver-types": "^3.17.2",
|
||||||
"vscode-uri": "^3.0.8",
|
"vscode-uri": "^3.0.8",
|
||||||
"yaml": "^2.1.1"
|
"yaml": "^2.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16.15"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
|
|||||||
@@ -7,6 +7,185 @@ const schema = schemaImport as any;
|
|||||||
|
|
||||||
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
|
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
|
||||||
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
|
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
|
||||||
|
const ALL_OUTPUT_PATH = "./src/context-providers/events/webhooks.all.json";
|
||||||
|
const ALL_OBJECTS_PATH = "./src/context-providers/events/objects.all.json";
|
||||||
|
const DROP_OUTPUT_PATH = "./src/context-providers/events/webhooks.drop.json";
|
||||||
|
const DROP_OBJECTS_PATH = "./src/context-providers/events/objects.drop.json";
|
||||||
|
const STRIP_OUTPUT_PATH = "./src/context-providers/events/webhooks.strip.json";
|
||||||
|
const STRIP_OBJECTS_PATH = "./src/context-providers/events/objects.strip.json";
|
||||||
|
|
||||||
|
// Parse --all flag
|
||||||
|
const generateAll = process.argv.includes("--all");
|
||||||
|
|
||||||
|
// Events to drop - not valid workflow triggers (GitHub App or API-only events)
|
||||||
|
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||||
|
const DROPPED_EVENTS = new Set([
|
||||||
|
"branch_protection_configuration",
|
||||||
|
"code_scanning_alert",
|
||||||
|
"commit_comment",
|
||||||
|
"custom_property",
|
||||||
|
"custom_property_values",
|
||||||
|
"dependabot_alert",
|
||||||
|
"deploy_key",
|
||||||
|
"github_app_authorization",
|
||||||
|
"installation",
|
||||||
|
"installation_repositories",
|
||||||
|
"installation_target",
|
||||||
|
"marketplace_purchase",
|
||||||
|
"member",
|
||||||
|
"membership",
|
||||||
|
"merge_group",
|
||||||
|
"meta",
|
||||||
|
"org_block",
|
||||||
|
"organization",
|
||||||
|
"package",
|
||||||
|
"personal_access_token_request",
|
||||||
|
"ping",
|
||||||
|
"repository",
|
||||||
|
"repository_advisory",
|
||||||
|
"repository_ruleset",
|
||||||
|
"secret_scanning_alert",
|
||||||
|
"secret_scanning_alert_location",
|
||||||
|
"security_advisory",
|
||||||
|
"security_and_analysis",
|
||||||
|
"sponsorship",
|
||||||
|
"star",
|
||||||
|
"team",
|
||||||
|
"team_add"
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Events to keep - valid workflow triggers
|
||||||
|
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||||
|
const KEPT_EVENTS = new Set([
|
||||||
|
"branch_protection_rule",
|
||||||
|
"check_run",
|
||||||
|
"check_suite",
|
||||||
|
"create",
|
||||||
|
"delete",
|
||||||
|
"deployment",
|
||||||
|
"deployment_status",
|
||||||
|
"discussion",
|
||||||
|
"discussion_comment",
|
||||||
|
"fork",
|
||||||
|
"gollum",
|
||||||
|
"issue_comment",
|
||||||
|
"issues",
|
||||||
|
"label",
|
||||||
|
"milestone",
|
||||||
|
"page_build",
|
||||||
|
"project",
|
||||||
|
"project_card",
|
||||||
|
"project_column",
|
||||||
|
"projects_v2",
|
||||||
|
"projects_v2_item",
|
||||||
|
"public",
|
||||||
|
"pull_request",
|
||||||
|
"pull_request_review",
|
||||||
|
"pull_request_review_comment",
|
||||||
|
"pull_request_review_thread",
|
||||||
|
"push",
|
||||||
|
"registry_package",
|
||||||
|
"release",
|
||||||
|
"repository_dispatch",
|
||||||
|
"repository_import",
|
||||||
|
"repository_vulnerability_alert",
|
||||||
|
"status",
|
||||||
|
"watch",
|
||||||
|
"workflow_dispatch",
|
||||||
|
"workflow_job",
|
||||||
|
"workflow_run"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields to strip from the JSON data.
|
||||||
|
*
|
||||||
|
* EVENT_ACTION_FIELDS: stripped from each event action object (top level only)
|
||||||
|
* Example event action object before stripping:
|
||||||
|
* {
|
||||||
|
* "description": "This event is triggered when...", // <-- stripped
|
||||||
|
* "summary": "A brief summary", // <-- stripped
|
||||||
|
* "availability": ["repository"], // <-- stripped
|
||||||
|
* "category": "issues", // <-- stripped
|
||||||
|
* "action": "opened", // kept
|
||||||
|
* "bodyParameters": [...] // kept
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* BODY_PARAM_FIELDS: stripped from every bodyParameters object, recursively through childParamsGroups
|
||||||
|
* Example bodyParameter object before stripping:
|
||||||
|
* {
|
||||||
|
* "type": "object", // <-- stripped
|
||||||
|
* "name": "changes", // kept (used for property names)
|
||||||
|
* "in": "body", // <-- stripped
|
||||||
|
* "description": "The changes that were made.", // kept (used for hover docs)
|
||||||
|
* "isRequired": true, // <-- stripped
|
||||||
|
* "enum": ["a", "b"], // <-- stripped
|
||||||
|
* "default": "a", // <-- stripped
|
||||||
|
* "childParamsGroups": [ // kept (used for nested properties)
|
||||||
|
* {
|
||||||
|
* "type": "string", // <-- stripped (recursive)
|
||||||
|
* "name": "from", // kept
|
||||||
|
* "isRequired": true // <-- stripped (recursive)
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category"];
|
||||||
|
const BODY_PARAM_FIELDS = ["type", "in", "isRequired", "enum", "default"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip fields from a bodyParameter object and recursively from childParamsGroups.
|
||||||
|
*/
|
||||||
|
function stripBodyParam(param: any): any {
|
||||||
|
if (typeof param !== "object" || param === null) {
|
||||||
|
return param;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: any = {};
|
||||||
|
for (const [key, value] of Object.entries(param)) {
|
||||||
|
if (BODY_PARAM_FIELDS.includes(key)) {
|
||||||
|
continue; // Strip this field
|
||||||
|
}
|
||||||
|
if (key === "childParamsGroups" && Array.isArray(value)) {
|
||||||
|
result[key] = value.map(stripBodyParam);
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip unused fields from event action data.
|
||||||
|
*/
|
||||||
|
function stripEventActionFields(action: any): any {
|
||||||
|
const result: any = {};
|
||||||
|
for (const [key, value] of Object.entries(action)) {
|
||||||
|
if (EVENT_ACTION_FIELDS.includes(key)) {
|
||||||
|
continue; // Strip this field
|
||||||
|
}
|
||||||
|
if (key === "bodyParameters" && Array.isArray(value)) {
|
||||||
|
result[key] = value.map((p: any) => (typeof p === "number" ? p : stripBodyParam(p)));
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip unused fields from all webhooks.
|
||||||
|
* Structure: { eventName: { actionName: { ...fields } } }
|
||||||
|
*/
|
||||||
|
function stripFields(webhooks: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
|
||||||
|
const result: Record<string, Record<string, any>> = {};
|
||||||
|
for (const [eventName, actions] of Object.entries(webhooks)) {
|
||||||
|
result[eventName] = {};
|
||||||
|
for (const [actionName, actionData] of Object.entries(actions)) {
|
||||||
|
result[eventName][actionName] = stripEventActionFields(actionData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
|
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
|
||||||
if (!rawWebhooks) {
|
if (!rawWebhooks) {
|
||||||
@@ -20,11 +199,51 @@ for (const webhook of Object.values(rawWebhooks)) {
|
|||||||
|
|
||||||
await Promise.all(webhooks.map(webhook => webhook.process()));
|
await Promise.all(webhooks.map(webhook => webhook.process()));
|
||||||
|
|
||||||
|
// Check for unknown events (not in DROPPED_EVENTS or KEPT_EVENTS)
|
||||||
|
const unknownEvents: string[] = [];
|
||||||
|
for (const webhook of webhooks) {
|
||||||
|
if (!DROPPED_EVENTS.has(webhook.category) && !KEPT_EVENTS.has(webhook.category)) {
|
||||||
|
if (!unknownEvents.includes(webhook.category)) {
|
||||||
|
unknownEvents.push(webhook.category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unknownEvents.length > 0) {
|
||||||
|
console.error("");
|
||||||
|
console.error("══════════════════════════════════════════════════════════════════");
|
||||||
|
console.error("ERROR: New webhook event(s) detected!");
|
||||||
|
console.error("══════════════════════════════════════════════════════════════════");
|
||||||
|
console.error("");
|
||||||
|
console.error("The following events are not categorized:");
|
||||||
|
for (const event of unknownEvents.sort()) {
|
||||||
|
console.error(` - ${event}`);
|
||||||
|
}
|
||||||
|
console.error("");
|
||||||
|
console.error("Action required:");
|
||||||
|
console.error(" 1. Check if the event is a valid workflow trigger:");
|
||||||
|
console.error(
|
||||||
|
" https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows"
|
||||||
|
);
|
||||||
|
console.error("");
|
||||||
|
console.error(" 2. Add the event to DROPPED_EVENTS or KEPT_EVENTS in:");
|
||||||
|
console.error(" languageservice/script/webhooks/index.ts");
|
||||||
|
console.error("");
|
||||||
|
console.error(" 3. See docs/json-data-files.md for more details.");
|
||||||
|
console.error("");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// The category is the name of the webhook
|
// The category is the name of the webhook
|
||||||
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
|
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
|
||||||
for (const webhook of webhooks) {
|
for (const webhook of webhooks) {
|
||||||
if (!webhook.action) webhook.action = "default";
|
if (!webhook.action) webhook.action = "default";
|
||||||
|
|
||||||
|
// Drop unused events
|
||||||
|
if (DROPPED_EVENTS.has(webhook.category)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (categorizedWebhooks[webhook.category]) {
|
if (categorizedWebhooks[webhook.category]) {
|
||||||
categorizedWebhooks[webhook.category][webhook.action] = webhook;
|
categorizedWebhooks[webhook.category][webhook.action] = webhook;
|
||||||
} else {
|
} else {
|
||||||
@@ -33,7 +252,59 @@ for (const webhook of webhooks) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
|
// Strip fields before deduplication
|
||||||
|
const strippedWebhooks = stripFields(categorizedWebhooks);
|
||||||
|
|
||||||
|
// Deduplicate after dropping and stripping
|
||||||
|
const objectsArray = deduplicateWebhooks(strippedWebhooks);
|
||||||
|
|
||||||
|
// Write optimized output
|
||||||
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
|
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
|
||||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
|
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, null, 2));
|
||||||
|
|
||||||
|
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(strippedWebhooks).length} events)`);
|
||||||
|
console.log(`Wrote ${OBJECTS_PATH} (${objectsArray.length} objects)`);
|
||||||
|
|
||||||
|
// Optionally generate intermediate versions for size comparison
|
||||||
|
if (generateAll) {
|
||||||
|
// Helper to deep clone
|
||||||
|
function clone<T>(obj: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full webhooks (no drop, no strip) from fresh data
|
||||||
|
const fullWebhooks: Record<string, Record<string, any>> = {};
|
||||||
|
for (const webhook of webhooks) {
|
||||||
|
const w = clone(webhook);
|
||||||
|
if (!w.action) w.action = "default";
|
||||||
|
fullWebhooks[w.category] ||= {};
|
||||||
|
fullWebhooks[w.category][w.action] = w;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate all version (no drop, no strip)
|
||||||
|
const allWebhooks = clone(fullWebhooks);
|
||||||
|
const allObjects = deduplicateWebhooks(allWebhooks);
|
||||||
|
await fs.writeFile(ALL_OUTPUT_PATH, JSON.stringify(allWebhooks, null, 2));
|
||||||
|
await fs.writeFile(ALL_OBJECTS_PATH, JSON.stringify(allObjects, null, 2));
|
||||||
|
console.log(`Wrote ${ALL_OUTPUT_PATH} (${Object.keys(allWebhooks).length} events)`);
|
||||||
|
console.log(`Wrote ${ALL_OBJECTS_PATH} (${allObjects.length} objects)`);
|
||||||
|
|
||||||
|
// Generate drop-only version (drop events, no strip)
|
||||||
|
const dropWebhooks = clone(fullWebhooks);
|
||||||
|
for (const event of DROPPED_EVENTS) {
|
||||||
|
delete dropWebhooks[event];
|
||||||
|
}
|
||||||
|
const dropObjects = deduplicateWebhooks(dropWebhooks);
|
||||||
|
await fs.writeFile(DROP_OUTPUT_PATH, JSON.stringify(dropWebhooks, null, 2));
|
||||||
|
await fs.writeFile(DROP_OBJECTS_PATH, JSON.stringify(dropObjects, null, 2));
|
||||||
|
console.log(`Wrote ${DROP_OUTPUT_PATH} (${Object.keys(dropWebhooks).length} events)`);
|
||||||
|
console.log(`Wrote ${DROP_OBJECTS_PATH} (${dropObjects.length} objects)`);
|
||||||
|
|
||||||
|
// Generate strip-only version (strip fields, no drop)
|
||||||
|
const stripWebhooks = stripFields(clone(fullWebhooks));
|
||||||
|
const stripObjects = deduplicateWebhooks(stripWebhooks);
|
||||||
|
await fs.writeFile(STRIP_OUTPUT_PATH, JSON.stringify(stripWebhooks, null, 2));
|
||||||
|
await fs.writeFile(STRIP_OBJECTS_PATH, JSON.stringify(stripObjects, null, 2));
|
||||||
|
console.log(`Wrote ${STRIP_OUTPUT_PATH} (${Object.keys(stripWebhooks).length} events)`);
|
||||||
|
console.log(`Wrote ${STRIP_OBJECTS_PATH} (${stripObjects.length} objects)`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -299,7 +299,16 @@ jobs:
|
|||||||
"on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo";
|
"on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo";
|
||||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
|
|
||||||
expect(result.map(x => x.label)).toEqual(["arch", "name", "os", "temp", "tool_cache"]);
|
expect(result.map(x => x.label)).toEqual([
|
||||||
|
"arch",
|
||||||
|
"debug",
|
||||||
|
"environment",
|
||||||
|
"name",
|
||||||
|
"os",
|
||||||
|
"temp",
|
||||||
|
"tool_cache",
|
||||||
|
"workspace"
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("job if", () => {
|
describe("job if", () => {
|
||||||
@@ -861,7 +870,7 @@ jobs:
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("strategy context", () => {
|
describe("strategy context", () => {
|
||||||
it("strategy is not suggested when outside of a matrix job", async () => {
|
it("strategy is suggested even when no strategy defined", async () => {
|
||||||
const input = `
|
const input = `
|
||||||
on: push
|
on: push
|
||||||
|
|
||||||
@@ -875,7 +884,7 @@ jobs:
|
|||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
|
|
||||||
expect(result.map(x => x.label)).not.toContain("strategy");
|
expect(result.map(x => x.label)).toContain("strategy");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strategy is suggested within a matrix job", async () => {
|
it("strategy is suggested within a matrix job", async () => {
|
||||||
@@ -922,7 +931,7 @@ jobs:
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("matrix context", () => {
|
describe("matrix context", () => {
|
||||||
it("matrix is not suggested when outside of a matrix job", async () => {
|
it("matrix is suggested even when no strategy defined", async () => {
|
||||||
const input = `
|
const input = `
|
||||||
on: push
|
on: push
|
||||||
|
|
||||||
@@ -936,7 +945,7 @@ jobs:
|
|||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
|
|
||||||
expect(result.map(x => x.label)).not.toContain("strategy");
|
expect(result.map(x => x.label)).toContain("matrix");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matrix is suggested within a matrix job", async () => {
|
it("matrix is suggested within a matrix job", async () => {
|
||||||
@@ -1123,10 +1132,12 @@ jobs:
|
|||||||
"github",
|
"github",
|
||||||
"inputs",
|
"inputs",
|
||||||
"job",
|
"job",
|
||||||
|
"matrix",
|
||||||
"needs",
|
"needs",
|
||||||
"runner",
|
"runner",
|
||||||
"secrets",
|
"secrets",
|
||||||
"steps",
|
"steps",
|
||||||
|
"strategy",
|
||||||
"vars",
|
"vars",
|
||||||
"contains",
|
"contains",
|
||||||
"endsWith",
|
"endsWith",
|
||||||
@@ -1268,7 +1279,7 @@ jobs:
|
|||||||
on: push
|
on: push
|
||||||
jobs:
|
jobs:
|
||||||
a:
|
a:
|
||||||
uses: ./reusable-workflow-with-outputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||||
b:
|
b:
|
||||||
needs: [a]
|
needs: [a]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import {complete} from "./complete";
|
||||||
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
|
import {clearCache} from "./utils/workflow-cache";
|
||||||
|
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Issue #81 - multi-line if expression completion", () => {
|
||||||
|
it("should complete in block scalar if with | (exact position)", async () => {
|
||||||
|
// Exact reproduction from issue - cursor after "github." in block scalar
|
||||||
|
const input = `on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: |
|
||||||
|
github.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo`;
|
||||||
|
|
||||||
|
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||||
|
// Line 5 (0-indexed) = " github.", character 13 = after the dot
|
||||||
|
const pos = {line: 5, character: 13};
|
||||||
|
|
||||||
|
const result = await complete(doc, pos, {});
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(result.map(x => x.label)).toContain("event");
|
||||||
|
expect(result.map(x => x.label)).toContain("actor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should complete in block scalar if with > (exact position)", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: >
|
||||||
|
github.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo`;
|
||||||
|
|
||||||
|
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||||
|
const pos = {line: 5, character: 13};
|
||||||
|
|
||||||
|
const result = await complete(doc, pos, {});
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(result.map(x => x.label)).toContain("event");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should complete in block scalar with multiple lines", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: |
|
||||||
|
github.event_name == 'push' &&
|
||||||
|
github.|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo`;
|
||||||
|
|
||||||
|
// Skip 1 to skip the `|` block scalar indicator (same character as cursor marker)
|
||||||
|
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(result.map(x => x.label)).toContain("event");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should complete step if in block scalar", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo
|
||||||
|
if: |
|
||||||
|
github.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||||
|
// Line 7 = " github.", character 15 = after the dot (8 spaces + 7 chars)
|
||||||
|
const pos = {line: 7, character: 15};
|
||||||
|
|
||||||
|
const result = await complete(doc, pos, {});
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(result.map(x => x.label)).toContain("event");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should complete in block scalar with ${{ expression markers", async () => {
|
||||||
|
// This case works because transform() skips lines with ${{
|
||||||
|
// Note: Using explicit position because | appears in multiple places (block scalar, ||, cursor)
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: |
|
||||||
|
\${{
|
||||||
|
github.ref == 'refs/heads/main' ||
|
||||||
|
github.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo`;
|
||||||
|
|
||||||
|
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||||
|
// Line 6 = " github." = 8 spaces + 7 chars = 15 chars, cursor after dot is at char 15
|
||||||
|
const pos = {line: 6, character: 15};
|
||||||
|
|
||||||
|
const result = await complete(doc, pos, {});
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(result.map(x => x.label)).toContain("ref");
|
||||||
|
expect(result.map(x => x.label)).toContain("ref_name");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge cases for getOffsetInContent", () => {
|
||||||
|
it("should complete in single-line if (not block scalar)", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: github.|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo`;
|
||||||
|
|
||||||
|
const result = await complete(...getPositionFromCursor(input), {});
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(result.map(x => x.label)).toContain("event");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should complete on third content line of block scalar", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: |
|
||||||
|
github.event_name == 'push' &&
|
||||||
|
github.ref == 'refs/heads/main' &&
|
||||||
|
github.|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo`;
|
||||||
|
|
||||||
|
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(result.map(x => x.label)).toContain("event");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should complete when block scalar has empty first line", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: |
|
||||||
|
|
||||||
|
github.|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo`;
|
||||||
|
|
||||||
|
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||||
|
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(result.map(x => x.label)).toContain("event");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow-with-inputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
|
|
|
|
||||||
`;
|
`;
|
||||||
@@ -49,7 +49,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow-with-inputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
username: monalisa
|
username: monalisa
|
||||||
|
|
|
|
||||||
@@ -74,7 +74,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow-with-inputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||||
secrets:
|
secrets:
|
||||||
|
|
|
|
||||||
`;
|
`;
|
||||||
@@ -102,7 +102,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow-with-inputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||||
secrets: |
|
secrets: |
|
||||||
`;
|
`;
|
||||||
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
|
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
|
||||||
@@ -117,7 +117,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow-with-inputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||||
secrets:
|
secrets:
|
||||||
envPAT: "myPAT"
|
envPAT: "myPAT"
|
||||||
|
|
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
|||||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
|
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
||||||
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
||||||
import {File} from "@actions/workflow-parser/workflows/file";
|
import {File} from "@actions/workflow-parser/workflows/file";
|
||||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||||
@@ -19,7 +20,6 @@ import {isPotentiallyExpression} from "./utils/expression-detection";
|
|||||||
import {findToken} from "./utils/find-token";
|
import {findToken} from "./utils/find-token";
|
||||||
import {guessIndentation} from "./utils/indentation-guesser";
|
import {guessIndentation} from "./utils/indentation-guesser";
|
||||||
import {mapRange} from "./utils/range";
|
import {mapRange} from "./utils/range";
|
||||||
import {getRelCharOffset} from "./utils/rel-char-pos";
|
|
||||||
import {isPlaceholder, transform} from "./utils/transform";
|
import {isPlaceholder, transform} from "./utils/transform";
|
||||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||||
import {Value, ValueProviderConfig} from "./value-providers/config";
|
import {Value, ValueProviderConfig} from "./value-providers/config";
|
||||||
@@ -238,12 +238,12 @@ function getExpressionCompletionItems(
|
|||||||
currentInput = stringToken.source || stringToken.value;
|
currentInput = stringToken.source || stringToken.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relCharOffset = getRelCharOffset(token.range, currentInput, pos);
|
const cursorOffset = getOffsetInContent(token.range, currentInput, pos);
|
||||||
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
|
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
|
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
|
||||||
mapExpressionCompletionItem(item, currentInput[relCharOffset])
|
mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
||||||
@@ -274,3 +274,50 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo
|
|||||||
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
|
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a document position to an offset within the token's content string.
|
||||||
|
*/
|
||||||
|
function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: Position): number {
|
||||||
|
const range = mapRange(tokenRange);
|
||||||
|
|
||||||
|
if (range.start.line === range.end.line) {
|
||||||
|
// Single-line example:
|
||||||
|
// if: github.ref == 'main'
|
||||||
|
// ^8 ^15 (cursor)
|
||||||
|
// currentInput = "github.ref == 'main'"
|
||||||
|
// offset = 15 - 8 = 7
|
||||||
|
return pos.character - range.start.character;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-line example:
|
||||||
|
// if: | <- line 3 (range.start.line)
|
||||||
|
// first line <- line 4, content line 0
|
||||||
|
// second line <- line 5, content line 1
|
||||||
|
// github. <- line 6, content line 2, cursor at index 11
|
||||||
|
// ^11 (cursor)
|
||||||
|
//
|
||||||
|
// currentInput = " first line\n second line\n github."
|
||||||
|
// ^0 ^15 ^32 ^43
|
||||||
|
|
||||||
|
// Line index within content.
|
||||||
|
// From the example:
|
||||||
|
// lineIndexWithinContent = pos.line - range.start.line - 1
|
||||||
|
// = 6 - 3 - 1 = 2
|
||||||
|
const lineIndexWithinContent = pos.line - range.start.line - 1;
|
||||||
|
|
||||||
|
// Length of content before current line.
|
||||||
|
// From the example:
|
||||||
|
// lengthOfContentBeforeCurrentLine => 14 + 1 = 15 (after first iteration)
|
||||||
|
// => 31 + 1 = 32 (after second iteration)
|
||||||
|
let lengthOfContentBeforeCurrentLine = 0;
|
||||||
|
for (let i = 0; i < lineIndexWithinContent; i++) {
|
||||||
|
lengthOfContentBeforeCurrentLine = currentInput.indexOf("\n", lengthOfContentBeforeCurrentLine) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final offset within content.
|
||||||
|
// From the example:
|
||||||
|
// finalOffset = lengthOfContentBeforeCurrentLine + pos.character
|
||||||
|
// = 32 + 11 = 43
|
||||||
|
return lengthOfContentBeforeCurrentLine + pos.character;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {DescriptionDictionary} from "@actions/expressions";
|
||||||
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
|
import {getContext, Mode} from "./default";
|
||||||
|
|
||||||
|
describe("getContext", () => {
|
||||||
|
const emptyWorkflowContext: WorkflowContext = {
|
||||||
|
uri: "test.yaml",
|
||||||
|
template: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("when no contextProviderConfig is provided", () => {
|
||||||
|
it("should mark secrets context as incomplete", async () => {
|
||||||
|
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||||
|
expect(secretsContext).toBeDefined();
|
||||||
|
expect(secretsContext.complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark vars context as incomplete", async () => {
|
||||||
|
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||||
|
expect(varsContext).toBeDefined();
|
||||||
|
expect(varsContext.complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not mark other contexts as incomplete", async () => {
|
||||||
|
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
const envContext = result.get("env") as DescriptionDictionary;
|
||||||
|
const githubContext = result.get("github") as DescriptionDictionary;
|
||||||
|
|
||||||
|
// These contexts are derived from the workflow file, so they can be complete
|
||||||
|
expect(envContext).toBeDefined();
|
||||||
|
expect(envContext.complete).toBe(true);
|
||||||
|
expect(githubContext).toBeDefined();
|
||||||
|
expect(githubContext.complete).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when contextProviderConfig returns a value", () => {
|
||||||
|
it("should use the provided context for secrets", async () => {
|
||||||
|
const providedContext = new DescriptionDictionary();
|
||||||
|
providedContext.complete = true; // Provider fetched from API, so it's complete
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
getContext: () => Promise.resolve(providedContext)
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
const secretsContext = result.get("secrets");
|
||||||
|
expect(secretsContext).toBe(providedContext);
|
||||||
|
expect((secretsContext as DescriptionDictionary).complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use the provided context for vars", async () => {
|
||||||
|
const providedContext = new DescriptionDictionary();
|
||||||
|
providedContext.complete = true;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
getContext: () => Promise.resolve(providedContext)
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
const varsContext = result.get("vars");
|
||||||
|
expect(varsContext).toBe(providedContext);
|
||||||
|
expect((varsContext as DescriptionDictionary).complete).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when contextProviderConfig returns undefined", () => {
|
||||||
|
it("should mark secrets as incomplete", async () => {
|
||||||
|
const config = {
|
||||||
|
getContext: () => Promise.resolve(undefined)
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||||
|
expect(secretsContext.complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark vars as incomplete", async () => {
|
||||||
|
const config = {
|
||||||
|
getContext: () => Promise.resolve(undefined)
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
|
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||||
|
expect(varsContext.complete).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,15 +32,24 @@ export async function getContext(
|
|||||||
): Promise<DescriptionDictionary> {
|
): Promise<DescriptionDictionary> {
|
||||||
const context = new DescriptionDictionary();
|
const context = new DescriptionDictionary();
|
||||||
|
|
||||||
const filteredNames = filterContextNames(names, workflowContext);
|
// All context names are valid - strategy and matrix are always available
|
||||||
for (const contextName of filteredNames) {
|
// (with default values when no strategy block is defined)
|
||||||
|
for (const contextName of names) {
|
||||||
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
|
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
|
||||||
if (value.kind === Kind.Null) {
|
if (value.kind === Kind.Null) {
|
||||||
context.add(contextName, value);
|
context.add(contextName, value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
value = (await config?.getContext(contextName, value, workflowContext, mode)) || value;
|
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
|
||||||
|
if (remoteValue) {
|
||||||
|
value = remoteValue;
|
||||||
|
} else if (contextName === "secrets" || contextName === "vars") {
|
||||||
|
// Without a context provider to fetch remote secrets/vars, we can't know
|
||||||
|
// what values exist, so mark the context as incomplete to avoid false
|
||||||
|
// "Context access might be invalid" warnings
|
||||||
|
value.complete = false;
|
||||||
|
}
|
||||||
|
|
||||||
context.add(contextName, value, getDescription(RootContext, contextName));
|
context.add(contextName, value, getDescription(RootContext, contextName));
|
||||||
}
|
}
|
||||||
@@ -74,11 +83,14 @@ function getDefaultContext(name: string, workflowContext: WorkflowContext, mode:
|
|||||||
|
|
||||||
case "runner":
|
case "runner":
|
||||||
return objectToDictionary({
|
return objectToDictionary({
|
||||||
os: "Linux",
|
|
||||||
arch: "X64",
|
arch: "X64",
|
||||||
|
debug: "1",
|
||||||
|
environment: "github-hosted",
|
||||||
name: "GitHub Actions 2",
|
name: "GitHub Actions 2",
|
||||||
|
os: "Linux",
|
||||||
|
temp: "/home/runner/work/_temp",
|
||||||
tool_cache: "/opt/hostedtoolcache",
|
tool_cache: "/opt/hostedtoolcache",
|
||||||
temp: "/home/runner/work/_temp"
|
workspace: "/home/runner/work/repo"
|
||||||
});
|
});
|
||||||
|
|
||||||
case "secrets":
|
case "secrets":
|
||||||
@@ -103,18 +115,3 @@ function objectToDictionary(object: {[key: string]: string}): DescriptionDiction
|
|||||||
|
|
||||||
return dictionary;
|
return dictionary;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
|
|
||||||
return contextNames.filter(name => {
|
|
||||||
switch (name) {
|
|
||||||
case "matrix":
|
|
||||||
case "strategy":
|
|
||||||
return hasStrategy(workflowContext);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasStrategy(workflowContext: WorkflowContext): boolean {
|
|
||||||
return workflowContext.job?.strategy !== undefined || workflowContext.reusableWorkflowJob?.strategy !== undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -239,7 +239,13 @@
|
|||||||
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software).\""
|
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software).\""
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `1`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
|
"description": "This is set only if [`ACTIONS_STEP_DEBUG`](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `\"1\"`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"description": "The environment of the runner executing the job. Possible values are `github-hosted` for GitHub-hosted runners, or `self-hosted` for self-hosted runners."
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"description": "The runner-specific working directory path for the job."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"strategy": {
|
"strategy": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import descriptions from "./descriptions.json";
|
import descriptions from "./descriptions.min.json";
|
||||||
|
|
||||||
export const RootContext = "root";
|
export const RootContext = "root";
|
||||||
const FunctionContext = "functions";
|
const FunctionContext = "functions";
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {DescriptionDictionary} from "@actions/expressions";
|
||||||
|
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads";
|
||||||
|
|
||||||
|
describe("eventPayloads", () => {
|
||||||
|
describe("getSupportedEventTypes", () => {
|
||||||
|
it("returns action types for push event", () => {
|
||||||
|
const types = getSupportedEventTypes("push");
|
||||||
|
expect(types).toContain("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns action types for issues event", () => {
|
||||||
|
const types = getSupportedEventTypes("issues");
|
||||||
|
expect(types.length).toBeGreaterThan(1);
|
||||||
|
expect(types).toContain("opened");
|
||||||
|
expect(types).toContain("closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEventPayload", () => {
|
||||||
|
it("returns payload for push event", () => {
|
||||||
|
const payload = getEventPayload("push", "default");
|
||||||
|
expect(payload).toBeDefined();
|
||||||
|
|
||||||
|
// Verify common fields exist
|
||||||
|
expect(payload?.get("ref")).toBeDefined();
|
||||||
|
expect(payload?.get("repository")).toBeDefined();
|
||||||
|
expect(payload?.get("sender")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns payload for issues event", () => {
|
||||||
|
const payload = getEventPayload("issues", "opened");
|
||||||
|
expect(payload).toBeDefined();
|
||||||
|
|
||||||
|
expect(payload?.get("action")).toBeDefined();
|
||||||
|
expect(payload?.get("issue")).toBeDefined();
|
||||||
|
expect(payload?.get("repository")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves descriptions for hover documentation", () => {
|
||||||
|
// This test ensures bodyParameters[].description is not stripped
|
||||||
|
// during JSON optimization. The description field is used for hover
|
||||||
|
// documentation in the workflow editor.
|
||||||
|
const payload = getEventPayload("push", "default");
|
||||||
|
expect(payload).toBeDefined();
|
||||||
|
|
||||||
|
// Get the description for a well-known field
|
||||||
|
// repository should have a description like "A repository on GitHub"
|
||||||
|
const repoDescription = payload?.getDescription("repository");
|
||||||
|
expect(repoDescription).toBeDefined();
|
||||||
|
expect(repoDescription?.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// sender should have a description
|
||||||
|
const senderDescription = payload?.getDescription("sender");
|
||||||
|
expect(senderDescription).toBeDefined();
|
||||||
|
expect(senderDescription?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves childParamsGroups for nested property access", () => {
|
||||||
|
// This test ensures bodyParameters[].childParamsGroups is not stripped
|
||||||
|
// during JSON optimization. childParamsGroups defines nested properties
|
||||||
|
// used for autocompletion like github.event.repository.owner.login
|
||||||
|
const payload = getEventPayload("push", "default");
|
||||||
|
expect(payload).toBeDefined();
|
||||||
|
|
||||||
|
// repository has nested properties like owner, license, etc.
|
||||||
|
const repository = payload?.get("repository") as DescriptionDictionary | undefined;
|
||||||
|
expect(repository).toBeDefined();
|
||||||
|
|
||||||
|
// repository.owner should exist (nested via childParamsGroups)
|
||||||
|
const owner = repository?.get("owner") as DescriptionDictionary | undefined;
|
||||||
|
expect(owner).toBeDefined();
|
||||||
|
|
||||||
|
// repository.owner.login should exist (deeply nested)
|
||||||
|
const login = owner?.get("login");
|
||||||
|
expect(login).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves name fields for property identification", () => {
|
||||||
|
// This test ensures bodyParameters[].name is not stripped
|
||||||
|
// during JSON optimization. The name field identifies each property.
|
||||||
|
const payload = getEventPayload("issues", "opened");
|
||||||
|
expect(payload).toBeDefined();
|
||||||
|
|
||||||
|
// Verify well-known property names exist
|
||||||
|
expect(payload?.get("action")).toBeDefined();
|
||||||
|
expect(payload?.get("issue")).toBeDefined();
|
||||||
|
expect(payload?.get("repository")).toBeDefined();
|
||||||
|
expect(payload?.get("sender")).toBeDefined();
|
||||||
|
|
||||||
|
// Verify nested property names work
|
||||||
|
const issue = payload?.get("issue") as DescriptionDictionary | undefined;
|
||||||
|
expect(issue?.get("title")).toBeDefined();
|
||||||
|
expect(issue?.get("number")).toBeDefined();
|
||||||
|
expect(issue?.get("user")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for unknown event", () => {
|
||||||
|
const payload = getEventPayload("not_a_real_event", "default");
|
||||||
|
expect(payload).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
|
|
||||||
import webhookObjects from "./objects.json";
|
import webhookObjects from "./objects.min.json";
|
||||||
import webhooks from "./webhooks.json";
|
import webhooks from "./webhooks.min.json";
|
||||||
|
|
||||||
import schedule from "./schedule.json";
|
import schedule from "./schedule.min.json";
|
||||||
import workflow_call from "./workflow_call.json";
|
import workflow_call from "./workflow_call.min.json";
|
||||||
|
|
||||||
const customEventPayloads: {[name: string]: unknown} = {
|
const customEventPayloads: {[name: string]: unknown} = {
|
||||||
schedule,
|
schedule,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,7 @@ describe("matrix context", () => {
|
|||||||
expect(workflowContext.job).toBeUndefined();
|
expect(workflowContext.job).toBeUndefined();
|
||||||
|
|
||||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||||
expect(context).toEqual(new DescriptionDictionary());
|
expect(context).toEqual(new data.Null());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strategy not defined", () => {
|
it("strategy not defined", () => {
|
||||||
@@ -73,7 +73,7 @@ describe("matrix context", () => {
|
|||||||
expect(workflowContext.job!.strategy).toBeUndefined();
|
expect(workflowContext.job!.strategy).toBeUndefined();
|
||||||
|
|
||||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||||
expect(context).toEqual(new DescriptionDictionary());
|
expect(context).toEqual(new data.Null());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strategy is not a mapping token", () => {
|
it("strategy is not a mapping token", () => {
|
||||||
@@ -81,7 +81,7 @@ describe("matrix context", () => {
|
|||||||
expect(workflowContext.job!.strategy).toBeDefined();
|
expect(workflowContext.job!.strategy).toBeDefined();
|
||||||
|
|
||||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||||
expect(context).toEqual(new DescriptionDictionary());
|
expect(context).toEqual(new data.Null());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matrix is not defined", () => {
|
it("matrix is not defined", () => {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode):
|
|||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
|
||||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||||
if (!strategy || !isMapping(strategy)) {
|
if (!strategy || !isMapping(strategy)) {
|
||||||
return new DescriptionDictionary();
|
// No strategy defined - matrix is null at runtime (not empty object)
|
||||||
|
return new data.Null();
|
||||||
}
|
}
|
||||||
|
|
||||||
const matrix = strategy.find("matrix");
|
const matrix = strategy.find("matrix");
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
on: push
|
on: push
|
||||||
jobs:
|
jobs:
|
||||||
a:
|
a:
|
||||||
uses: ./reusable-workflow-with-outputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||||
|
|
||||||
b:
|
b:
|
||||||
needs: [a]
|
needs: [a]
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||||
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
|
import {getStepsContext} from "./steps";
|
||||||
|
|
||||||
|
function createWorkflowContext(stepIds: string[], currentStepId?: string): WorkflowContext {
|
||||||
|
return {
|
||||||
|
job: {
|
||||||
|
steps: stepIds.map(id => ({id}))
|
||||||
|
},
|
||||||
|
step: currentStepId ? {id: currentStepId} : undefined
|
||||||
|
} as WorkflowContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("steps context", () => {
|
||||||
|
it("returns empty dictionary when no job", () => {
|
||||||
|
const workflowContext = {} as WorkflowContext;
|
||||||
|
const context = getStepsContext(workflowContext);
|
||||||
|
expect(context.pairs().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty dictionary when no steps", () => {
|
||||||
|
const workflowContext = {job: {}} as WorkflowContext;
|
||||||
|
const context = getStepsContext(workflowContext);
|
||||||
|
expect(context.pairs().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes steps with user-defined ids", () => {
|
||||||
|
const workflowContext = createWorkflowContext(["step-a", "step-b"]);
|
||||||
|
const context = getStepsContext(workflowContext);
|
||||||
|
|
||||||
|
expect(context.get("step-a")).toBeDefined();
|
||||||
|
expect(context.get("step-b")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes generated step ids (starting with __)", () => {
|
||||||
|
const workflowContext = createWorkflowContext(["step-a", "__generated"]);
|
||||||
|
const context = getStepsContext(workflowContext);
|
||||||
|
|
||||||
|
expect(context.get("step-a")).toBeDefined();
|
||||||
|
expect(context.get("__generated")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes current step and later steps", () => {
|
||||||
|
const workflowContext = createWorkflowContext(["step-a", "step-b", "step-c"], "step-b");
|
||||||
|
const context = getStepsContext(workflowContext);
|
||||||
|
|
||||||
|
expect(context.get("step-a")).toBeDefined();
|
||||||
|
expect(context.get("step-b")).toBeUndefined();
|
||||||
|
expect(context.get("step-c")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("step outputs", () => {
|
||||||
|
it("outputs is a dictionary, not null", () => {
|
||||||
|
const workflowContext = createWorkflowContext(["step-a"]);
|
||||||
|
const context = getStepsContext(workflowContext);
|
||||||
|
|
||||||
|
const stepContext = context.get("step-a");
|
||||||
|
expect(stepContext).toBeDefined();
|
||||||
|
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
||||||
|
|
||||||
|
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||||
|
expect(outputs).toBeDefined();
|
||||||
|
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outputs is marked incomplete to allow dynamic outputs", () => {
|
||||||
|
const workflowContext = createWorkflowContext(["step-a"]);
|
||||||
|
const context = getStepsContext(workflowContext);
|
||||||
|
|
||||||
|
const stepContext = context.get("step-a") as DescriptionDictionary;
|
||||||
|
const outputs = stepContext.get("outputs") as DescriptionDictionary;
|
||||||
|
|
||||||
|
// Outputs should be incomplete since we can't know what outputs a step will produce
|
||||||
|
expect(outputs.complete).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,7 +31,10 @@ function stepContext(): DescriptionDictionary {
|
|||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context
|
||||||
const d = new DescriptionDictionary();
|
const d = new DescriptionDictionary();
|
||||||
|
|
||||||
d.add("outputs", new data.Null(), getDescription("steps", "outputs"));
|
// Step outputs are dynamic - actions can generate outputs based on their inputs
|
||||||
|
const outputs = new DescriptionDictionary();
|
||||||
|
outputs.complete = false;
|
||||||
|
d.add("outputs", outputs, getDescription("steps", "outputs"));
|
||||||
|
|
||||||
// Can be "success", "failure", "cancelled", or "skipped"
|
// Can be "success", "failure", "cancelled", or "skipped"
|
||||||
d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));
|
d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {data} from "@actions/expressions";
|
||||||
|
import {Job} from "@actions/workflow-parser/model/workflow-template";
|
||||||
|
import {BooleanToken} from "@actions/workflow-parser/templates/tokens/boolean-token";
|
||||||
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
|
import {NumberToken} from "@actions/workflow-parser/templates/tokens/number-token";
|
||||||
|
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||||
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
|
import {getStrategyContext} from "./strategy";
|
||||||
|
|
||||||
|
function stringToToken(value: string) {
|
||||||
|
return new StringToken(undefined, undefined, value, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolToToken(value: boolean) {
|
||||||
|
return new BooleanToken(undefined, undefined, value, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberToToken(value: number) {
|
||||||
|
return new NumberToken(undefined, undefined, value, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextFromStrategy(strategy?: TemplateToken) {
|
||||||
|
return {
|
||||||
|
job: {
|
||||||
|
strategy: strategy
|
||||||
|
}
|
||||||
|
} as WorkflowContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("strategy context", () => {
|
||||||
|
describe("no strategy defined", () => {
|
||||||
|
it("returns defaults when job is undefined", () => {
|
||||||
|
const workflowContext = {} as WorkflowContext;
|
||||||
|
|
||||||
|
const context = getStrategyContext(workflowContext);
|
||||||
|
|
||||||
|
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||||
|
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||||
|
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||||
|
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns defaults when strategy is undefined", () => {
|
||||||
|
const job = {} as Job;
|
||||||
|
const workflowContext = {job} as WorkflowContext;
|
||||||
|
|
||||||
|
const context = getStrategyContext(workflowContext);
|
||||||
|
|
||||||
|
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||||
|
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||||
|
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||||
|
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns defaults when strategy is not a mapping", () => {
|
||||||
|
const workflowContext = contextFromStrategy(stringToToken("hello"));
|
||||||
|
|
||||||
|
const context = getStrategyContext(workflowContext);
|
||||||
|
|
||||||
|
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||||
|
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||||
|
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||||
|
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("strategy defined with partial properties", () => {
|
||||||
|
it("uses specified fail-fast, defaults for others", () => {
|
||||||
|
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||||
|
strategy.add(stringToToken("fail-fast"), boolToToken(false));
|
||||||
|
const workflowContext = contextFromStrategy(strategy);
|
||||||
|
|
||||||
|
const context = getStrategyContext(workflowContext);
|
||||||
|
|
||||||
|
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
|
||||||
|
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||||
|
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||||
|
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses specified max-parallel, defaults for others", () => {
|
||||||
|
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||||
|
strategy.add(stringToToken("max-parallel"), numberToToken(5));
|
||||||
|
const workflowContext = contextFromStrategy(strategy);
|
||||||
|
|
||||||
|
const context = getStrategyContext(workflowContext);
|
||||||
|
|
||||||
|
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||||
|
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||||
|
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||||
|
expect(context.get("max-parallel")).toEqual(new data.NumberData(5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only has matrix defined, all strategy properties use defaults", () => {
|
||||||
|
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||||
|
const matrix = new MappingToken(undefined, undefined, undefined);
|
||||||
|
strategy.add(stringToToken("matrix"), matrix);
|
||||||
|
const workflowContext = contextFromStrategy(strategy);
|
||||||
|
|
||||||
|
const context = getStrategyContext(workflowContext);
|
||||||
|
|
||||||
|
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||||
|
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||||
|
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||||
|
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("strategy with all properties defined", () => {
|
||||||
|
it("uses all specified values", () => {
|
||||||
|
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||||
|
strategy.add(stringToToken("fail-fast"), boolToToken(false));
|
||||||
|
strategy.add(stringToToken("max-parallel"), numberToToken(3));
|
||||||
|
const workflowContext = contextFromStrategy(strategy);
|
||||||
|
|
||||||
|
const context = getStrategyContext(workflowContext);
|
||||||
|
|
||||||
|
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
|
||||||
|
// job-index and job-total are runtime values, not specified in YAML
|
||||||
|
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||||
|
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||||
|
expect(context.get("max-parallel")).toEqual(new data.NumberData(3));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,15 +3,24 @@ import {isMapping, isScalar, isString} from "@actions/workflow-parser";
|
|||||||
import {WorkflowContext} from "../context/workflow-context";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
import {scalarToData} from "../utils/scalar-to-data";
|
import {scalarToData} from "../utils/scalar-to-data";
|
||||||
|
|
||||||
|
// Default strategy values when no strategy block is defined
|
||||||
|
const DEFAULT_STRATEGY = {
|
||||||
|
"fail-fast": new data.BooleanData(true),
|
||||||
|
"job-index": new data.NumberData(0),
|
||||||
|
"job-total": new data.NumberData(1),
|
||||||
|
"max-parallel": new data.NumberData(1)
|
||||||
|
};
|
||||||
|
|
||||||
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
|
||||||
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
|
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
|
||||||
|
|
||||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||||
if (!strategy || !isMapping(strategy)) {
|
if (!strategy || !isMapping(strategy)) {
|
||||||
|
// No strategy defined - return defaults that match runtime behavior
|
||||||
return new DescriptionDictionary(
|
return new DescriptionDictionary(
|
||||||
...keys.map(key => {
|
...keys.map(key => {
|
||||||
return {key, value: new data.Null()};
|
return {key, value: DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -31,7 +40,8 @@ export function getStrategyContext(workflowContext: WorkflowContext): Descriptio
|
|||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (!strategyContext.get(key)) {
|
if (!strategyContext.get(key)) {
|
||||||
strategyContext.add(key, new data.Null());
|
// Use default value for missing properties
|
||||||
|
strategyContext.add(key, DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow-with-inputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
us|ername:
|
us|ername:
|
||||||
`;
|
`;
|
||||||
@@ -31,7 +31,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow-with-inputs-no-description.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs-no-description.yaml
|
||||||
with:
|
with:
|
||||||
us|ername:
|
us|ername:
|
||||||
`;
|
`;
|
||||||
@@ -48,7 +48,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow-with-outputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||||
echo_outputs:
|
echo_outputs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
|
|||||||
@@ -110,11 +110,8 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
const result = await hover(...getPositionFromCursor(input));
|
const result = await hover(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result?.contents).toEqual(
|
// Cron description is now shown via diagnostics, not hover
|
||||||
"Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00\n\n" +
|
expect(result?.contents).toEqual("");
|
||||||
"Actions schedules run at most every 5 minutes. " +
|
|
||||||
"[Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("on a cron mapping key", async () => {
|
it("on a cron mapping key", async () => {
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import {data, DescriptionDictionary, Parser} from "@actions/expressions";
|
|||||||
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
|
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
|
||||||
import {Lexer} from "@actions/expressions/lexer";
|
import {Lexer} from "@actions/expressions/lexer";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
|
|
||||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
import {isBasicExpression, isString} from "@actions/workflow-parser/templates/tokens/type-guards";
|
import {isBasicExpression} from "@actions/workflow-parser/templates/tokens/type-guards";
|
||||||
import {File} from "@actions/workflow-parser/workflows/file";
|
import {File} from "@actions/workflow-parser/workflows/file";
|
||||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||||
@@ -23,7 +21,7 @@ import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-p
|
|||||||
import {HoverVisitor} from "./expression-hover/visitor";
|
import {HoverVisitor} from "./expression-hover/visitor";
|
||||||
import {info} from "./log";
|
import {info} from "./log";
|
||||||
import {isPotentiallyExpression} from "./utils/expression-detection";
|
import {isPotentiallyExpression} from "./utils/expression-detection";
|
||||||
import {findToken, TokenResult} from "./utils/find-token";
|
import {findToken} from "./utils/find-token";
|
||||||
import {mapRange} from "./utils/range";
|
import {mapRange} from "./utils/range";
|
||||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||||
|
|
||||||
@@ -89,17 +87,6 @@ export async function hover(document: TextDocument, position: Position, config?:
|
|||||||
|
|
||||||
info(`Calculating hover for token with definition ${token.definition.key}`);
|
info(`Calculating hover for token with definition ${token.definition.key}`);
|
||||||
|
|
||||||
if (tokenResult.parent && isCronMappingValue(tokenResult)) {
|
|
||||||
const tokenValue = (token as StringToken).value;
|
|
||||||
const description = getCronDescription(tokenValue);
|
|
||||||
if (description) {
|
|
||||||
return {
|
|
||||||
contents: description,
|
|
||||||
range: mapRange(token.range)
|
|
||||||
} satisfies Hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
||||||
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
|
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
|
||||||
description = appendContext(description, token.definitionInfo?.allowedContext);
|
description = appendContext(description, token.definitionInfo?.allowedContext);
|
||||||
@@ -156,15 +143,6 @@ async function getDescription(
|
|||||||
return description || defaultDescription;
|
return description || defaultDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCronMappingValue(tokenResult: TokenResult): boolean {
|
|
||||||
return (
|
|
||||||
tokenResult.parent?.definition?.key === "cron-mapping" &&
|
|
||||||
!!tokenResult.token &&
|
|
||||||
isString(tokenResult.token) &&
|
|
||||||
tokenResult.token.value !== "cron"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function expressionHover(
|
function expressionHover(
|
||||||
exprPos: ExpressionPos,
|
exprPos: ExpressionPos,
|
||||||
context: DescriptionDictionary,
|
context: DescriptionDictionary,
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ export const testFileProvider: FileProvider = {
|
|||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
getFileContent: async ref => {
|
getFileContent: async ref => {
|
||||||
switch (fileIdentifier(ref)) {
|
switch (fileIdentifier(ref)) {
|
||||||
case "monalisa/octocat/workflow.yaml@main":
|
case "monalisa/octocat/.github/workflows/workflow.yaml@main":
|
||||||
return {
|
return {
|
||||||
name: "monalisa/octocat/workflow.yaml",
|
name: "monalisa/octocat/.github/workflows/workflow.yaml",
|
||||||
content: `
|
content: `
|
||||||
on: workflow_call
|
on: workflow_call
|
||||||
jobs:
|
jobs:
|
||||||
@@ -31,9 +31,9 @@ jobs:
|
|||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
case "./reusable-workflow.yaml":
|
case "./.github/workflows/reusable-workflow.yaml":
|
||||||
return {
|
return {
|
||||||
name: "reusable-workflow.yaml",
|
name: ".github/workflows/reusable-workflow.yaml",
|
||||||
content: `
|
content: `
|
||||||
on: workflow_call
|
on: workflow_call
|
||||||
jobs:
|
jobs:
|
||||||
@@ -44,9 +44,9 @@ jobs:
|
|||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
case "./reusable-workflow-with-inputs.yaml":
|
case "./.github/workflows/reusable-workflow-with-inputs.yaml":
|
||||||
return {
|
return {
|
||||||
name: "reusable-workflow-with-inputs.yaml",
|
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
|
||||||
content: `
|
content: `
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
@@ -76,9 +76,9 @@ jobs:
|
|||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
case "./reusable-workflow-with-inputs-no-description.yaml":
|
case "./.github/workflows/reusable-workflow-with-inputs-no-description.yaml":
|
||||||
return {
|
return {
|
||||||
name: "reusable-workflow-with-inputs.yaml",
|
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
|
||||||
content: `
|
content: `
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
@@ -95,9 +95,9 @@ jobs:
|
|||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
case "./reusable-workflow-with-outputs.yaml":
|
case "./.github/workflows/reusable-workflow-with-outputs.yaml":
|
||||||
return {
|
return {
|
||||||
name: "reusable-workflow-with-outputs.yaml",
|
name: ".github/workflows/reusable-workflow-with-outputs.yaml",
|
||||||
content: `
|
content: `
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
|
||||||
import {Position} from "vscode-languageserver-textdocument";
|
|
||||||
import {mapRange} from "./range";
|
|
||||||
|
|
||||||
export function getRelCharOffset(tokenRange: TokenRange, currentInput: string, pos: Position): number {
|
|
||||||
const range = mapRange(tokenRange);
|
|
||||||
if (range.start.line !== range.end.line) {
|
|
||||||
const lines = currentInput.split("\n");
|
|
||||||
const lineDiff = pos.line - range.start.line - 1;
|
|
||||||
const linesBeforeCusor = lines.slice(0, lineDiff);
|
|
||||||
return linesBeforeCusor.join("\n").length + pos.character + 1;
|
|
||||||
} else {
|
|
||||||
return pos.character - range.start.character;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
|
import {validate} from "./validate";
|
||||||
|
import {createDocument} from "./test-utils/document";
|
||||||
|
import {clearCache} from "./utils/workflow-cache";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validate concurrency deadlock", () => {
|
||||||
|
describe("should error on matching concurrency groups", () => {
|
||||||
|
it("simple string match", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency: test
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: test
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(2);
|
||||||
|
|
||||||
|
// Workflow-level warning
|
||||||
|
expect(concurrencyErrors[0]).toMatchObject({
|
||||||
|
message: "Concurrency group 'test' is also used by job 'job1'. This will cause a deadlock.",
|
||||||
|
severity: DiagnosticSeverity.Error
|
||||||
|
});
|
||||||
|
|
||||||
|
// Job-level warning
|
||||||
|
expect(concurrencyErrors[1]).toMatchObject({
|
||||||
|
message: "Concurrency group 'test' is also defined at the workflow level. This will cause a deadlock.",
|
||||||
|
severity: DiagnosticSeverity.Error
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("workflow mapping form, job string form", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency:
|
||||||
|
group: my-group
|
||||||
|
cancel-in-progress: true
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: my-group
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(2);
|
||||||
|
expect(concurrencyErrors[0].message).toContain("my-group");
|
||||||
|
expect(concurrencyErrors[0].message).toContain("deploy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("workflow string form, job mapping form", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency: deploy-group
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: deploy-group
|
||||||
|
cancel-in-progress: true
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(2);
|
||||||
|
expect(concurrencyErrors[0].message).toContain("deploy-group");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("both mapping forms", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency:
|
||||||
|
group: shared
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: shared
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple jobs with matching concurrency", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency: shared
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: shared
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
job2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: shared
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
// Should have 2 warnings per job (workflow + job) = 4 total, but workflow is only warned once per match
|
||||||
|
// Actually: 1 workflow warning per matching job + 1 job warning per matching job = 4 total
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should not warn", () => {
|
||||||
|
it("different concurrency groups", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency: workflow-group
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: job-group
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("workflow concurrency is an expression", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency: \${{ github.ref }}
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: test
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("job concurrency is an expression", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency: test
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: \${{ github.ref }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no workflow-level concurrency", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: test
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no job-level concurrency", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency: test
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("case sensitive - different case is different group", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency: Test
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: test
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("workflow concurrency group in mapping is an expression", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
concurrency:
|
||||||
|
group: \${{ github.ref }}
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: test
|
||||||
|
steps:
|
||||||
|
- run: echo hi`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
|
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||||
|
expect(concurrencyErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -635,7 +635,7 @@ jobs:
|
|||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
node: [14, 16]
|
node: [14, 16]
|
||||||
uses: ./reusable-workflow-with-inputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
username: User-\${{ strategy.fail-fast }}
|
username: User-\${{ strategy.fail-fast }}
|
||||||
`;
|
`;
|
||||||
@@ -654,7 +654,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: [14, 16]
|
node: [14, 16]
|
||||||
uses: ./reusable-workflow-with-inputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
username: \${{ matrix.node }}
|
username: \${{ matrix.node }}
|
||||||
`;
|
`;
|
||||||
@@ -681,7 +681,8 @@ jobs:
|
|||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
expect(result).not.toEqual([]);
|
// Strategy context is always available with default values
|
||||||
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invalid strategy property", async () => {
|
it("invalid strategy property", async () => {
|
||||||
@@ -996,22 +997,8 @@ jobs:
|
|||||||
|
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
|
||||||
expect(result).toEqual([
|
// Matrix is null when no strategy is defined, accessing properties on null is valid
|
||||||
{
|
expect(result).toEqual([]);
|
||||||
message: "Context access might be invalid: matrix",
|
|
||||||
range: {
|
|
||||||
end: {
|
|
||||||
character: 36,
|
|
||||||
line: 8
|
|
||||||
},
|
|
||||||
start: {
|
|
||||||
character: 18,
|
|
||||||
line: 8
|
|
||||||
}
|
|
||||||
},
|
|
||||||
severity: DiagnosticSeverity.Warning
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("basic matrix", async () => {
|
it("basic matrix", async () => {
|
||||||
@@ -1609,6 +1596,48 @@ jobs:
|
|||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows runner.environment context", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- if: runner.environment == 'github-hosted'
|
||||||
|
run: echo hello`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows runner.debug context", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- if: runner.debug == '1'
|
||||||
|
run: echo hello`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows runner.workspace context", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- if: runner.workspace != ''
|
||||||
|
run: echo hello`;
|
||||||
|
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows env context", async () => {
|
it("allows env context", async () => {
|
||||||
const input = `
|
const input = `
|
||||||
on: push
|
on: push
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Test validation behavior when no context providers are configured.
|
||||||
|
*
|
||||||
|
* When contextProviderConfig is not provided (or returns incomplete data),
|
||||||
|
* we should skip validation for secrets/vars rather than showing false
|
||||||
|
* positive "Context access might be invalid" warnings.
|
||||||
|
*
|
||||||
|
* This is important for offline/disconnected scenarios where API calls
|
||||||
|
* to fetch secrets/vars are not possible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {validate} from "./validate";
|
||||||
|
import {createDocument} from "./test-utils/document";
|
||||||
|
import {clearCache} from "./utils/workflow-cache";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validation without context providers", () => {
|
||||||
|
describe("secrets context", () => {
|
||||||
|
it("should not warn on secrets.GITHUB_TOKEN", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "test"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not warn on custom secrets when no provider configured", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "test"
|
||||||
|
env:
|
||||||
|
API_KEY: \${{ secrets.MY_API_KEY }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not warn on secrets with environment", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- run: echo "test"
|
||||||
|
env:
|
||||||
|
API_KEY: \${{ secrets.API_KEY }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vars context", () => {
|
||||||
|
it("should not warn on vars when no provider configured", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "\${{ vars.ENVIRONMENT }}"
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not warn on vars with environment", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- run: echo "\${{ vars.API_URL }}"
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not warn on vars with fallback pattern", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "\${{ vars.OPTIONAL_VAR || 'default-value' }}"
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combined secrets and vars", () => {
|
||||||
|
it("should not warn on workflow using both secrets and vars", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
echo "Deploying to \${{ vars.API_URL }}"
|
||||||
|
echo "Using region \${{ vars.AWS_REGION }}"
|
||||||
|
env:
|
||||||
|
API_KEY: \${{ secrets.API_KEY }}
|
||||||
|
AWS_SECRET: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -181,7 +181,7 @@ jobs:
|
|||||||
|
|
||||||
expect(result.length).toBe(1);
|
expect(result.length).toBe(1);
|
||||||
expect(result[0]).toEqual({
|
expect(result[0]).toEqual({
|
||||||
message: "Invalid cron string",
|
message: "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
|
||||||
range: {
|
range: {
|
||||||
end: {
|
end: {
|
||||||
character: 21,
|
character: 21,
|
||||||
@@ -195,6 +195,96 @@ jobs:
|
|||||||
} as Diagnostic);
|
} as Diagnostic);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("cron with interval less than 5 minutes shows warning", async () => {
|
||||||
|
const result = await validate(
|
||||||
|
createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`on:
|
||||||
|
schedule:
|
||||||
|
- cron: '*/1 * * * *'
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest`
|
||||||
|
),
|
||||||
|
{valueProviderConfig: defaultValueProviders}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
message:
|
||||||
|
'Actions schedules run at most every 5 minutes. "*/1 * * * *" (runs every minute) will not run as frequently as specified.',
|
||||||
|
severity: DiagnosticSeverity.Warning,
|
||||||
|
code: "on-schedule",
|
||||||
|
codeDescription: {
|
||||||
|
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
|
||||||
|
},
|
||||||
|
range: {
|
||||||
|
end: {
|
||||||
|
character: 25,
|
||||||
|
line: 2
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
character: 12,
|
||||||
|
line: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as Diagnostic);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cron with interval of 5 minutes or more shows info", async () => {
|
||||||
|
const result = await validate(
|
||||||
|
createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`on:
|
||||||
|
schedule:
|
||||||
|
- cron: '*/5 * * * *'
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest`
|
||||||
|
),
|
||||||
|
{valueProviderConfig: defaultValueProviders}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
message: "Runs every 5 minutes",
|
||||||
|
severity: DiagnosticSeverity.Information,
|
||||||
|
code: "on-schedule",
|
||||||
|
codeDescription: {
|
||||||
|
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
|
||||||
|
},
|
||||||
|
range: {
|
||||||
|
end: {
|
||||||
|
character: 25,
|
||||||
|
line: 2
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
character: 12,
|
||||||
|
line: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as Diagnostic);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cron with comma-separated minutes less than 5 apart shows warning", async () => {
|
||||||
|
const result = await validate(
|
||||||
|
createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0,2 * * * *'
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest`
|
||||||
|
),
|
||||||
|
{valueProviderConfig: defaultValueProviders}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
expect(result[0]?.severity).toBe(DiagnosticSeverity.Warning);
|
||||||
|
expect(result[0]?.message).toContain("Actions schedules run at most every 5 minutes.");
|
||||||
|
});
|
||||||
|
|
||||||
it("invalid YAML", async () => {
|
it("invalid YAML", async () => {
|
||||||
// This YAML has some mismatched single-quotes, which causes the string to be terminated early
|
// This YAML has some mismatched single-quotes, which causes the string to be terminated early
|
||||||
// within the fromJSON() expression.
|
// within the fromJSON() expression.
|
||||||
@@ -295,4 +385,31 @@ jobs:
|
|||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("workflow_dispatch", () => {
|
||||||
|
it("allows empty string in choice options", async () => {
|
||||||
|
const result = await validate(
|
||||||
|
createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
plugin-name:
|
||||||
|
description: Specific plugin to build
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- ''
|
||||||
|
- foo
|
||||||
|
- bar
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {Lexer, Parser, data} from "@actions/expressions";
|
import {Lexer, Parser, data} from "@actions/expressions";
|
||||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||||
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
|
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
|
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
||||||
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||||
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
|
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
|
||||||
@@ -27,6 +28,9 @@ import {validateAction} from "./validate-action";
|
|||||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||||
import {defaultValueProviders} from "./value-providers/default";
|
import {defaultValueProviders} from "./value-providers/default";
|
||||||
|
|
||||||
|
const CRON_SCHEDULE_DOCS_URL =
|
||||||
|
"https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule";
|
||||||
|
|
||||||
export type ValidationConfig = {
|
export type ValidationConfig = {
|
||||||
valueProviderConfig?: ValueProviderConfig;
|
valueProviderConfig?: ValueProviderConfig;
|
||||||
contextProviderConfig?: ContextProviderConfig;
|
contextProviderConfig?: ContextProviderConfig;
|
||||||
@@ -143,11 +147,34 @@ async function additionalValidations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate step uses field format
|
||||||
|
if (isString(token) && token.range && validationDefinition?.key === "step-uses") {
|
||||||
|
validateStepUsesFormat(diagnostics, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate action metadata (inputs, required fields) for regular steps
|
||||||
if (token.definition?.key === "regular-step" && token.range) {
|
if (token.definition?.key === "regular-step" && token.range) {
|
||||||
const context = getProviderContext(documentUri, template, root, token.range);
|
const context = getProviderContext(documentUri, template, root, token.range);
|
||||||
await validateAction(diagnostics, token, context.step, config);
|
await validateAction(diagnostics, token, context.step, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate job-level reusable workflow uses field format
|
||||||
|
if (
|
||||||
|
isString(token) &&
|
||||||
|
token.range &&
|
||||||
|
key &&
|
||||||
|
isString(key) &&
|
||||||
|
key.value === "uses" &&
|
||||||
|
parent?.definition?.key === "workflow-job"
|
||||||
|
) {
|
||||||
|
validateWorkflowUsesFormat(diagnostics, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cron expressions - warn if interval is less than 5 minutes
|
||||||
|
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
|
||||||
|
validateCronExpression(diagnostics, token);
|
||||||
|
}
|
||||||
|
|
||||||
// Allowed values coming from the schema have already been validated. Only check if
|
// Allowed values coming from the schema have already been validated. Only check if
|
||||||
// a value provider is defined for a token and if it is, validate the values match.
|
// a value provider is defined for a token and if it is, validate the values match.
|
||||||
if (token.range && validationDefinition) {
|
if (token.range && validationDefinition) {
|
||||||
@@ -182,6 +209,9 @@ async function additionalValidations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate concurrency deadlock between workflow and job levels
|
||||||
|
validateConcurrencyDeadlock(diagnostics, template);
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
|
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
|
||||||
@@ -198,6 +228,357 @@ function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: Value
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates cron expressions and provides diagnostics for valid cron schedules.
|
||||||
|
* Shows a warning if the interval is less than 5 minutes (since GitHub Actions
|
||||||
|
* schedules run at most every 5 minutes), otherwise shows an info message.
|
||||||
|
*/
|
||||||
|
function validateCronExpression(diagnostics: Diagnostic[], token: StringToken): void {
|
||||||
|
const cronValue = token.value;
|
||||||
|
|
||||||
|
// Ensure we have a range for diagnostics
|
||||||
|
if (!token.range) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check valid cron expressions - invalid ones are already caught by the parser
|
||||||
|
const description = getCronDescription(cronValue);
|
||||||
|
if (!description) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the cron specifies an interval less than 5 minutes
|
||||||
|
if (hasCronIntervalLessThan5Minutes(cronValue)) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Actions schedules run at most every 5 minutes. "${cronValue}" (${description.toLowerCase()}) will not run as frequently as specified.`,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
severity: DiagnosticSeverity.Warning,
|
||||||
|
code: "on-schedule",
|
||||||
|
codeDescription: {
|
||||||
|
href: CRON_SCHEDULE_DOCS_URL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Show info message for valid cron expressions
|
||||||
|
diagnostics.push({
|
||||||
|
message: description,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
severity: DiagnosticSeverity.Information,
|
||||||
|
code: "on-schedule",
|
||||||
|
codeDescription: {
|
||||||
|
href: CRON_SCHEDULE_DOCS_URL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the format of a step's `uses` field.
|
||||||
|
*
|
||||||
|
* Valid formats:
|
||||||
|
* - docker://image:tag
|
||||||
|
* - ./local/path
|
||||||
|
* - .\local\path (Windows)
|
||||||
|
* - {owner}/{repo}@{ref}
|
||||||
|
* - {owner}/{repo}/{path}@{ref}
|
||||||
|
*/
|
||||||
|
function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
||||||
|
const uses = token.value;
|
||||||
|
|
||||||
|
// Empty uses value
|
||||||
|
if (!uses) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: "`uses' value in action cannot be blank",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Docker image reference - always valid format
|
||||||
|
if (uses.startsWith("docker://")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local action path - always valid format
|
||||||
|
if (uses.startsWith("./") || uses.startsWith(".\\")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote action: must be {owner}/{repo}[/path]@{ref}
|
||||||
|
const atSegments = uses.split("@");
|
||||||
|
|
||||||
|
// Must have exactly one @
|
||||||
|
if (atSegments.length !== 2) {
|
||||||
|
addStepUsesFormatError(diagnostics, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [repoPath, gitRef] = atSegments;
|
||||||
|
|
||||||
|
// Ref cannot be empty
|
||||||
|
if (!gitRef) {
|
||||||
|
addStepUsesFormatError(diagnostics, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by / or \ to get path segments
|
||||||
|
const pathSegments = repoPath.split(/[\\/]/);
|
||||||
|
|
||||||
|
// Must have at least owner and repo (both non-empty)
|
||||||
|
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
|
||||||
|
addStepUsesFormatError(diagnostics, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a reusable workflow reference (should be at job level, not step)
|
||||||
|
// Path would be like: owner/repo/.github/workflows/file.yml
|
||||||
|
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
|
||||||
|
diagnostics.push({
|
||||||
|
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the format of a job's `uses` field (reusable workflow reference).
|
||||||
|
*
|
||||||
|
* Valid formats:
|
||||||
|
* - {owner}/{repo}/.github/workflows/{filename}.yml@{ref}
|
||||||
|
* - {owner}/{repo}/.github/workflows/{filename}.yaml@{ref}
|
||||||
|
* - {owner}/{repo}/.github/workflows-lab/{filename}.yml@{ref}
|
||||||
|
* - {owner}/{repo}/.github/workflows-lab/{filename}.yaml@{ref}
|
||||||
|
* - ./.github/workflows/{filename}.yml
|
||||||
|
* - ./.github/workflows/{filename}.yaml
|
||||||
|
* - ./.github/workflows-lab/{filename}.yml
|
||||||
|
* - ./.github/workflows-lab/{filename}.yaml
|
||||||
|
*/
|
||||||
|
function validateWorkflowUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
||||||
|
const uses = token.value;
|
||||||
|
|
||||||
|
// Local workflow reference
|
||||||
|
if (uses.startsWith("./.github/workflows/") || uses.startsWith("./.github/workflows-lab/")) {
|
||||||
|
// Cannot have @ version for local workflows
|
||||||
|
if (uses.includes("@")) {
|
||||||
|
addWorkflowUsesFormatError(diagnostics, token, "cannot specify version when calling local workflows");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have .yml or .yaml extension
|
||||||
|
if (!uses.endsWith(".yml") && !uses.endsWith(".yaml")) {
|
||||||
|
addWorkflowUsesFormatError(
|
||||||
|
diagnostics,
|
||||||
|
token,
|
||||||
|
"workflow file should have either a '.yml' or '.yaml' file extension"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be at top level of .github/workflows/ or .github/workflows-lab/ (no subdirectories)
|
||||||
|
const pathParts = uses.split("/");
|
||||||
|
if (pathParts.length !== 4) {
|
||||||
|
// Expected: ".", ".github", "workflows" or "workflows-lab", "filename.yml"
|
||||||
|
addWorkflowUsesFormatError(
|
||||||
|
diagnostics,
|
||||||
|
token,
|
||||||
|
"workflows must be defined at the top level of the .github/workflows/ directory"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filename cannot be just the extension
|
||||||
|
const filename = pathParts[3];
|
||||||
|
if (filename === ".yml" || filename === ".yaml") {
|
||||||
|
addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed local workflow reference (starts with ./ but not in .github/workflows)
|
||||||
|
if (uses.startsWith("./")) {
|
||||||
|
addWorkflowUsesFormatError(diagnostics, token, "local workflow references must be rooted in '.github/workflows'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote workflow reference: must have @ for version
|
||||||
|
const atSegments = uses.split("@");
|
||||||
|
if (atSegments.length === 1) {
|
||||||
|
addWorkflowUsesFormatError(diagnostics, token, "no version specified");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (atSegments.length > 2) {
|
||||||
|
addWorkflowUsesFormatError(diagnostics, token, "too many '@' in workflow reference");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [pathPart, version] = atSegments;
|
||||||
|
|
||||||
|
// Version cannot be empty
|
||||||
|
if (!version) {
|
||||||
|
addWorkflowUsesFormatError(diagnostics, token, "no version specified");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must contain .github/workflows or .github/workflows-lab path
|
||||||
|
const workflowsMatch = pathPart.match(/\.github\/workflows(-lab)?\//);
|
||||||
|
if (!workflowsMatch || workflowsMatch.index === undefined) {
|
||||||
|
addWorkflowUsesFormatError(diagnostics, token, "references to workflows must be rooted in '.github/workflows'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split to get owner/repo and path
|
||||||
|
const pathIdx = workflowsMatch.index;
|
||||||
|
const nwoPart = pathPart.substring(0, pathIdx);
|
||||||
|
const workflowPath = pathPart.substring(pathIdx);
|
||||||
|
|
||||||
|
// Validate NWO part: must be owner/repo/
|
||||||
|
const nwoSegments = nwoPart.split("/").filter(s => s.length > 0);
|
||||||
|
if (nwoSegments.length !== 2) {
|
||||||
|
addWorkflowUsesFormatError(
|
||||||
|
diagnostics,
|
||||||
|
token,
|
||||||
|
"references to workflows must be prefixed with format 'owner/repository/' or './' for local workflows"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate owner and repo names
|
||||||
|
const [owner, repo] = nwoSegments;
|
||||||
|
const nwoError = validateNWO(owner, repo);
|
||||||
|
if (nwoError) {
|
||||||
|
addWorkflowUsesFormatError(diagnostics, token, nwoError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ref/version format
|
||||||
|
const refError = validateRefName(version);
|
||||||
|
if (refError) {
|
||||||
|
addWorkflowUsesFormatError(diagnostics, token, refError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate workflow path is at top level
|
||||||
|
const workflowPathParts = workflowPath.split("/");
|
||||||
|
if (workflowPathParts.length !== 3) {
|
||||||
|
// Expected: ".github", "workflows" or "workflows-lab", "filename.yml"
|
||||||
|
addWorkflowUsesFormatError(
|
||||||
|
diagnostics,
|
||||||
|
token,
|
||||||
|
"workflows must be defined at the top level of the .github/workflows/ directory"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have .yml or .yaml extension
|
||||||
|
const filename = workflowPathParts[2];
|
||||||
|
if (!filename.endsWith(".yml") && !filename.endsWith(".yaml")) {
|
||||||
|
addWorkflowUsesFormatError(
|
||||||
|
diagnostics,
|
||||||
|
token,
|
||||||
|
"workflow file should have either a '.yml' or '.yaml' file extension"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filename cannot be just the extension
|
||||||
|
if (filename === ".yml" || filename === ".yaml") {
|
||||||
|
addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWorkflowUsesFormatError(diagnostics: Diagnostic[], token: StringToken, reason: string): void {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Invalid workflow reference '${token.value}': ${reason}`,
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the git ref/version format.
|
||||||
|
* Based on Launch's ValidateRefName function.
|
||||||
|
*/
|
||||||
|
function validateRefName(refname: string): string | undefined {
|
||||||
|
if (refname.length === 0) {
|
||||||
|
return "no version specified";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot be the single character '@'
|
||||||
|
if (refname === "@") {
|
||||||
|
return "version cannot be the single character '@'";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot have certain invalid characters or sequences
|
||||||
|
const invalidSequences = ["?", "*", "[", "]", "\\", "~", "^", ":", "@{", "..", "//"];
|
||||||
|
for (const seq of invalidSequences) {
|
||||||
|
if (refname.includes(seq)) {
|
||||||
|
return `invalid character '${seq}' in version`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot begin or end with a slash '/' or a dot '.'
|
||||||
|
if (refname.startsWith("/") || refname.endsWith("/") || refname.startsWith(".") || refname.endsWith(".")) {
|
||||||
|
return "version cannot begin or end with a slash '/' or a dot '.'";
|
||||||
|
}
|
||||||
|
|
||||||
|
// No slash-separated component can begin with a dot '.' or end with the sequence '.lock'
|
||||||
|
const components = refname.split("/");
|
||||||
|
for (const component of components) {
|
||||||
|
if (component.startsWith(".") || component.endsWith(".lock")) {
|
||||||
|
return `invalid version: ${refname}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No ASCII control characters or whitespace
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (/[\x00-\x1f\x7f]/.test(refname)) {
|
||||||
|
return "version cannot have ASCII control characters";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\s/.test(refname)) {
|
||||||
|
return "version cannot have whitespace";
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates owner and repository names.
|
||||||
|
* Based on Launch's ValidateNWO function.
|
||||||
|
*/
|
||||||
|
function validateNWO(owner: string, repo: string): string | undefined {
|
||||||
|
// Owner name: can have word chars, dots, and hyphens
|
||||||
|
// \w in JS regex is [a-zA-Z0-9_]
|
||||||
|
if (!/^[\w.-]+$/.test(owner)) {
|
||||||
|
return "owner name must be a valid repository owner name";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository name: can have word chars, dots, and hyphens
|
||||||
|
if (!/^[\w.-]+$/.test(repo)) {
|
||||||
|
return "repository name is invalid";
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function getProviderContext(
|
function getProviderContext(
|
||||||
documentUri: URI,
|
documentUri: URI,
|
||||||
template: WorkflowTemplate,
|
template: WorkflowTemplate,
|
||||||
@@ -334,3 +715,71 @@ async function validateExpression(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that workflow-level and job-level concurrency groups don't match,
|
||||||
|
* which would cause a deadlock at runtime.
|
||||||
|
*/
|
||||||
|
function validateConcurrencyDeadlock(diagnostics: Diagnostic[], template: WorkflowTemplate): void {
|
||||||
|
const workflowGroup = getStaticConcurrencyGroup(template.concurrency);
|
||||||
|
if (!workflowGroup) {
|
||||||
|
return; // No workflow-level concurrency or it's an expression
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const job of template.jobs || []) {
|
||||||
|
if (!job.concurrency) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobGroup = getStaticConcurrencyGroup(job.concurrency);
|
||||||
|
if (!jobGroup) {
|
||||||
|
continue; // Job concurrency is an expression
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowGroup.value === jobGroup.value) {
|
||||||
|
// Error on workflow-level concurrency
|
||||||
|
if (template.concurrency.range) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Concurrency group '${workflowGroup.value}' is also used by job '${job.id.value}'. This will cause a deadlock.`,
|
||||||
|
range: mapRange(template.concurrency.range),
|
||||||
|
severity: DiagnosticSeverity.Error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error on job-level concurrency
|
||||||
|
if (job.concurrency.range) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Concurrency group '${jobGroup.value}' is also defined at the workflow level. This will cause a deadlock.`,
|
||||||
|
range: mapRange(job.concurrency.range),
|
||||||
|
severity: DiagnosticSeverity.Error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the static concurrency group name from a concurrency token.
|
||||||
|
* Returns undefined if the token is an expression or doesn't have a static group.
|
||||||
|
*/
|
||||||
|
function getStaticConcurrencyGroup(token: TemplateToken | undefined): StringToken | undefined {
|
||||||
|
if (!token || token.isExpression) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple string form: concurrency: "test"
|
||||||
|
if (isString(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping form: concurrency: { group: "test", cancel-in-progress: true }
|
||||||
|
if (isMapping(token)) {
|
||||||
|
for (const pair of token) {
|
||||||
|
if (isString(pair.key) && pair.key.value === "group" && isString(pair.value) && !pair.value.isExpression) {
|
||||||
|
return pair.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,894 @@
|
|||||||
|
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
|
import {validate} from "./validate";
|
||||||
|
import {createDocument} from "./test-utils/document";
|
||||||
|
import {clearCache} from "./utils/workflow-cache";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validate uses format", () => {
|
||||||
|
describe("valid formats", () => {
|
||||||
|
it("standard org/repo@ref", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("org/repo with path @ref", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/aws/ec2@main
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("org/repo with deep path @ref", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/aws/nested/deep/path@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("docker image", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: docker://alpine:3.8
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("docker image with registry", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: docker://gcr.io/my-project/my-image:latest
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("local path with ./", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: ./my-action
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("local path with ./ and subdirectories", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: ./.github/actions/my-action
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("local path with .\\ (Windows)", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: .\\my-action
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SHA ref", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("branch ref", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: owner/repo@feature/my-branch
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid formats", () => {
|
||||||
|
it("missing @ref", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 5, character: 12},
|
||||||
|
end: {line: 5, character: 28}
|
||||||
|
},
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty ref", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout@'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 5, character: 12},
|
||||||
|
end: {line: 5, character: 29}
|
||||||
|
},
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing org/owner", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: checkout@v4
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'checkout@v4'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 5, character: 12},
|
||||||
|
end: {line: 5, character: 23}
|
||||||
|
},
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty owner", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: /repo@v4
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual '/repo@v4'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 5, character: 12},
|
||||||
|
end: {line: 5, character: 20}
|
||||||
|
},
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty repo", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: owner/@v4
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'owner/@v4'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 5, character: 12},
|
||||||
|
end: {line: 5, character: 21}
|
||||||
|
},
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple @ symbols", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4@extra
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout@v4@extra'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 5, character: 12},
|
||||||
|
end: {line: 5, character: 37}
|
||||||
|
},
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("just a name with no slash", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: checkout
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'checkout'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 5, character: 12},
|
||||||
|
end: {line: 5, character: 20}
|
||||||
|
},
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty uses value", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: ""
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toContainEqual({
|
||||||
|
message: "`uses' value in action cannot be blank",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 5, character: 12},
|
||||||
|
end: {line: 5, character: 14}
|
||||||
|
},
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reusable workflow in step", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: owner/repo/.github/workflows/test.yml@main
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 5, character: 12},
|
||||||
|
end: {line: 5, character: 54}
|
||||||
|
},
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("workflow uses format validation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("valid formats", () => {
|
||||||
|
it("local workflow path", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: ./.github/workflows/test.yml
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("local workflow path with yaml extension", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: ./.github/workflows/test.yaml
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remote workflow with version", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remote workflow with sha ref", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@abc123
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remote workflow with branch ref", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@main
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remote workflow with yaml extension", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yaml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("local workflows-lab path", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: ./.github/workflows-lab/test.yml
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("local workflows-lab path with yaml extension", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: ./.github/workflows-lab/test.yaml
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remote workflows-lab with version", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows-lab/test.yml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid formats", () => {
|
||||||
|
it("remote workflow missing version", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Invalid workflow reference 'owner/repo/.github/workflows/test.yml': no version specified",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 47}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("local workflow with version", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: ./.github/workflows/test.yml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference './.github/workflows/test.yml@v1': cannot specify version when calling local workflows",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 41}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("malformed local path not in .github/workflows", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: ./foo/bar.yml
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference './foo/bar.yml': local workflow references must be rooted in '.github/workflows'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 23}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing .github/workflows path", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/test.yml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo/test.yml@v1': references to workflows must be rooted in '.github/workflows'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 32}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalid file extension", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.txt@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo/.github/workflows/test.txt@v1': workflow file should have either a '.yml' or '.yaml' file extension",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 50}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no extension", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo/.github/workflows/test@v1': workflow file should have either a '.yml' or '.yaml' file extension",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 46}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("just a ref", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: test.yml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'test.yml@v1': references to workflows must be rooted in '.github/workflows'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 21}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("local without .github/workflows", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: ./workflows/test.yml
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference './workflows/test.yml': local workflow references must be rooted in '.github/workflows'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 30}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid ref/version format", () => {
|
||||||
|
it("empty version after @", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@': no version specified",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 48}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("version with invalid character ?", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@v1?
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1?': invalid character '?' in version",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 51}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("version with double dots", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@v1..v2
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1..v2': invalid character '..' in version",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 54}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("version ending with dot", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@v1.
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1.': version cannot begin or end with a slash '/' or a dot '.'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 51}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("version starting with slash", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@/v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@/v1': version cannot begin or end with a slash '/' or a dot '.'",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 51}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("version ending with .lock", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@refs/heads/main.lock
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@refs/heads/main.lock': invalid version: refs/heads/main.lock",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 68}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("version with whitespace", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@v1 && rm -rf
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1 && rm -rf': version cannot have whitespace",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 60}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("version with backslash", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/test.yml@v1\\1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1\\1': invalid character '\\' in version",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 52}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid owner/repo names", () => {
|
||||||
|
it("owner with invalid characters", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner*/repo/.github/workflows/test.yml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner*/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 51}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repo with invalid characters", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo!name/.github/workflows/test.yml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner/repo!name/.github/workflows/test.yml@v1': repository name is invalid",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 55}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("owner with spaces", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner name/repo/.github/workflows/test.yml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid workflow reference 'owner name/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 55}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid workflow filename", () => {
|
||||||
|
it("filename is just .yml", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/.yml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yml@v1': invalid workflow file name",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 46}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filename is just .yaml", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: owner/repo/.github/workflows/.yaml@v1
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yaml@v1': invalid workflow file name",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 47}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("local workflow filename is just .yml", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
uses: ./.github/workflows/.yml
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
message: "Invalid workflow reference './.github/workflows/.yml': invalid workflow file name",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: {
|
||||||
|
start: {line: 3, character: 10},
|
||||||
|
end: {line: 3, character: 34}
|
||||||
|
},
|
||||||
|
code: "invalid-workflow-uses-format"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,7 +43,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: monalisa/octocat/workflow.yaml@not-a-branch
|
uses: monalisa/octocat/.github/workflows/workflow.yaml@not-a-branch
|
||||||
`;
|
`;
|
||||||
const result = await validate(createDocument("wf.yaml", input), {
|
const result = await validate(createDocument("wf.yaml", input), {
|
||||||
fileProvider: testFileProvider
|
fileProvider: testFileProvider
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
line: 5
|
line: 5
|
||||||
},
|
},
|
||||||
end: {
|
end: {
|
||||||
character: 53,
|
character: 71,
|
||||||
line: 5
|
line: 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: monalisa/octocat/workflow.yaml@main
|
uses: monalisa/octocat/.github/workflows/workflow.yaml@main
|
||||||
`;
|
`;
|
||||||
const result = await validate(createDocument("wf.yaml", input), {
|
const result = await validate(createDocument("wf.yaml", input), {
|
||||||
fileProvider: testFileProvider
|
fileProvider: testFileProvider
|
||||||
@@ -87,7 +87,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow.yaml
|
uses: ./.github/workflows/reusable-workflow.yaml
|
||||||
`;
|
`;
|
||||||
const result = await validate(createDocument("wf.yaml", input), {
|
const result = await validate(createDocument("wf.yaml", input), {
|
||||||
fileProvider: testFileProvider
|
fileProvider: testFileProvider
|
||||||
@@ -102,7 +102,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow-with-inputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||||
secrets:
|
secrets:
|
||||||
envPAT: pat
|
envPAT: pat
|
||||||
`;
|
`;
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
line: 5
|
line: 5
|
||||||
},
|
},
|
||||||
end: {
|
end: {
|
||||||
character: 46,
|
character: 64,
|
||||||
line: 5
|
line: 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ on: push
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./reusable-workflow-with-inputs.yaml
|
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||||
with:
|
with:
|
||||||
username: monalisa
|
username: monalisa
|
||||||
secrets:
|
secrets:
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import {validate} from "./validate";
|
||||||
|
import {createDocument} from "./test-utils/document";
|
||||||
|
import {clearCache} from "./utils/workflow-cache";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("YAML anchors and aliases", () => {
|
||||||
|
it("should handle anchors and aliases in env", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env: &env
|
||||||
|
ENV1: env1
|
||||||
|
ENV2: env2
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
job2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env: *env
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple aliases to the same anchor", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
env: &shared
|
||||||
|
SHARED: true
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env: *shared
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
job2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env: *shared
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
job3:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env: *shared
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle anchors in matrix strategy", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include: &matrix-include
|
||||||
|
- os: ubuntu-latest
|
||||||
|
node: 18
|
||||||
|
- os: windows-latest
|
||||||
|
node: 20
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
test2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include: *matrix-include
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle anchors in steps", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- &checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- run: npm test
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- *checkout
|
||||||
|
- run: npm run deploy
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle scalar anchors", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: &runner ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
test:
|
||||||
|
runs-on: *runner
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work without anchors (control test)", async () => {
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
job1:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ENV1: env1
|
||||||
|
ENV2: env2
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
job2:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ENV1: env1
|
||||||
|
ENV2: env2
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle circular aliases without hanging", async () => {
|
||||||
|
// This is an invalid use case (alias referencing parent) but should not hang
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env: &myenv
|
||||||
|
FOO: bar
|
||||||
|
nested: *myenv
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
`
|
||||||
|
);
|
||||||
|
// Should complete without hanging - circular portion is silently ignored
|
||||||
|
// which may cause downstream validation errors, but that's acceptable
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle undefined alias references", async () => {
|
||||||
|
// Reference to non-existent anchor - yaml library should report error
|
||||||
|
const doc = createDocument(
|
||||||
|
"wf.yaml",
|
||||||
|
`
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env: *nonexistent
|
||||||
|
steps:
|
||||||
|
- run: exit 0
|
||||||
|
`
|
||||||
|
);
|
||||||
|
const result = await validate(doc);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -6,5 +6,5 @@
|
|||||||
"languageservice",
|
"languageservice",
|
||||||
"languageserver"
|
"languageserver"
|
||||||
],
|
],
|
||||||
"version": "0.3.20"
|
"version": "0.3.25"
|
||||||
}
|
}
|
||||||
Generated
+88
-171
@@ -135,7 +135,7 @@
|
|||||||
},
|
},
|
||||||
"expressions": {
|
"expressions": {
|
||||||
"name": "@actions/expressions",
|
"name": "@actions/expressions",
|
||||||
"version": "0.3.20",
|
"version": "0.3.25",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.0.3",
|
"@types/jest": "^29.0.3",
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
"typescript": "^4.7.4"
|
"typescript": "^4.7.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16.15"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"expressions/node_modules/@eslint/eslintrc": {
|
"expressions/node_modules/@eslint/eslintrc": {
|
||||||
@@ -340,9 +340,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"expressions/node_modules/js-yaml": {
|
"expressions/node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -395,11 +395,11 @@
|
|||||||
},
|
},
|
||||||
"languageserver": {
|
"languageserver": {
|
||||||
"name": "@actions/languageserver",
|
"name": "@actions/languageserver",
|
||||||
"version": "0.3.20",
|
"version": "0.3.25",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/languageservice": "^0.3.20",
|
"@actions/languageservice": "^0.3.25",
|
||||||
"@actions/workflow-parser": "^0.3.20",
|
"@actions/workflow-parser": "^0.3.25",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
"@octokit/types": "^9.0.0",
|
"@octokit/types": "^9.0.0",
|
||||||
"vscode-languageserver": "^8.0.2",
|
"vscode-languageserver": "^8.0.2",
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
"typescript": "^4.8.4"
|
"typescript": "^4.8.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16.15"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"languageserver/node_modules/@eslint/eslintrc": {
|
"languageserver/node_modules/@eslint/eslintrc": {
|
||||||
@@ -921,11 +921,11 @@
|
|||||||
},
|
},
|
||||||
"languageservice": {
|
"languageservice": {
|
||||||
"name": "@actions/languageservice",
|
"name": "@actions/languageservice",
|
||||||
"version": "0.3.20",
|
"version": "0.3.25",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.20",
|
"@actions/expressions": "^0.3.25",
|
||||||
"@actions/workflow-parser": "^0.3.20",
|
"@actions/workflow-parser": "^0.3.25",
|
||||||
"vscode-languageserver-textdocument": "^1.0.7",
|
"vscode-languageserver-textdocument": "^1.0.7",
|
||||||
"vscode-languageserver-types": "^3.17.2",
|
"vscode-languageserver-types": "^3.17.2",
|
||||||
"vscode-uri": "^3.0.8",
|
"vscode-uri": "^3.0.8",
|
||||||
@@ -947,7 +947,7 @@
|
|||||||
"typescript": "^4.8.4"
|
"typescript": "^4.8.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16.15"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"languageservice/node_modules/@eslint/eslintrc": {
|
"languageservice/node_modules/@eslint/eslintrc": {
|
||||||
@@ -1136,9 +1136,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"languageservice/node_modules/js-yaml": {
|
"languageservice/node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -1218,89 +1218,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.22.13",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
|
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/highlight": "^7.22.13",
|
"@babel/helper-validator-identifier": "^7.27.1",
|
||||||
"chalk": "^2.4.2"
|
"js-tokens": "^4.0.0",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame/node_modules/ansi-styles": {
|
|
||||||
"version": "3.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
|
||||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"color-convert": "^1.9.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame/node_modules/chalk": {
|
|
||||||
"version": "2.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
|
||||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"ansi-styles": "^3.2.1",
|
|
||||||
"escape-string-regexp": "^1.0.5",
|
|
||||||
"supports-color": "^5.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame/node_modules/color-convert": {
|
|
||||||
"version": "1.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
|
||||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "1.1.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame/node_modules/color-name": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame/node_modules/escape-string-regexp": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame/node_modules/has-flag": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame/node_modules/supports-color": {
|
|
||||||
"version": "5.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
|
||||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"has-flag": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.20.1",
|
"version": "7.20.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1483,18 +1413,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.22.5",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
|
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.22.20",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||||
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
|
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -1509,13 +1439,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.20.1",
|
"version": "7.28.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
|
||||||
|
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.18.10",
|
"@babel/template": "^7.27.2",
|
||||||
"@babel/traverse": "^7.20.1",
|
"@babel/types": "^7.28.4"
|
||||||
"@babel/types": "^7.20.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -1600,10 +1530,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.23.0",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||||
"integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==",
|
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.28.5"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
},
|
},
|
||||||
@@ -1786,14 +1719,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.22.15",
|
"version": "7.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||||
"integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
|
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.22.13",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/parser": "^7.22.15",
|
"@babel/parser": "^7.27.2",
|
||||||
"@babel/types": "^7.22.15"
|
"@babel/types": "^7.27.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -1821,14 +1754,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.23.0",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||||
"integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
|
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.22.5",
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
"@babel/helper-validator-identifier": "^7.22.20",
|
"@babel/helper-validator-identifier": "^7.28.5"
|
||||||
"to-fast-properties": "^2.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -4231,12 +4163,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
|
"node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
|
||||||
"version": "7.3.8",
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^6.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
@@ -4361,12 +4291,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||||
"version": "7.3.8",
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^6.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
@@ -4402,12 +4330,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils/node_modules/semver": {
|
"node_modules/@typescript-eslint/utils/node_modules/semver": {
|
||||||
"version": "7.3.8",
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^6.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
@@ -4877,9 +4803,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
@@ -6265,12 +6192,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/semver": {
|
"node_modules/eslint/node_modules/semver": {
|
||||||
"version": "7.3.8",
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^6.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
@@ -8392,12 +8317,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jest-snapshot/node_modules/semver": {
|
"node_modules/jest-snapshot/node_modules/semver": {
|
||||||
"version": "7.3.8",
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^6.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
@@ -8510,9 +8433,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "3.14.1",
|
"version": "3.14.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||||
|
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^1.0.7",
|
"argparse": "^1.0.7",
|
||||||
"esprima": "^4.0.0"
|
"esprima": "^4.0.0"
|
||||||
@@ -9514,11 +9438,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"braces": "^3.0.2",
|
"braces": "^3.0.3",
|
||||||
"picomatch": "^2.3.1"
|
"picomatch": "^2.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -10760,9 +10685,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.0.0",
|
"version": "1.1.1",
|
||||||
"dev": true,
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"license": "ISC"
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
@@ -11524,9 +11450,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.0",
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
@@ -12147,14 +12074,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/to-fast-properties": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -12235,12 +12154,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-jest/node_modules/semver": {
|
"node_modules/ts-jest/node_modules/semver": {
|
||||||
"version": "7.3.8",
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^6.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
@@ -12917,10 +12834,10 @@
|
|||||||
},
|
},
|
||||||
"workflow-parser": {
|
"workflow-parser": {
|
||||||
"name": "@actions/workflow-parser",
|
"name": "@actions/workflow-parser",
|
||||||
"version": "0.3.20",
|
"version": "0.3.25",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.20",
|
"@actions/expressions": "^0.3.25",
|
||||||
"cronstrue": "^2.21.0",
|
"cronstrue": "^2.21.0",
|
||||||
"yaml": "^2.0.0-8"
|
"yaml": "^2.0.0-8"
|
||||||
},
|
},
|
||||||
@@ -12938,7 +12855,7 @@
|
|||||||
"typescript": "^4.8.4"
|
"typescript": "^4.8.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16.15"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "actions-languageservices",
|
"name": "actions-languageservices",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./expressions",
|
"./expressions",
|
||||||
"./workflow-parser",
|
"./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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/workflow-parser",
|
"name": "@actions/workflow-parser",
|
||||||
"version": "0.3.20",
|
"version": "0.3.25",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
@@ -38,19 +38,22 @@
|
|||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||||
|
"minify-json": "node ../script/minify-json.js src/workflow-v1.0.json",
|
||||||
|
"prebuild": "npm run minify-json",
|
||||||
"prepublishOnly": "npm run build && npm run test",
|
"prepublishOnly": "npm run build && npm run test",
|
||||||
|
"pretest": "npm run minify-json",
|
||||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||||
"test-xlang": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathPattern xlang",
|
"test-xlang": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathPattern xlang",
|
||||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.20",
|
"@actions/expressions": "^0.3.25",
|
||||||
"cronstrue": "^2.21.0",
|
"cronstrue": "^2.21.0",
|
||||||
"yaml": "^2.0.0-8"
|
"yaml": "^2.0.0-8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16.15"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {isValidCron, getCronDescription} from "./cron";
|
import {isValidCron, getCronDescription, hasCronIntervalLessThan5Minutes} from "./cron";
|
||||||
|
|
||||||
describe("cron", () => {
|
describe("cron", () => {
|
||||||
describe("valid cron", () => {
|
describe("valid cron", () => {
|
||||||
@@ -66,14 +66,54 @@ describe("cron", () => {
|
|||||||
|
|
||||||
describe("getCronDescription", () => {
|
describe("getCronDescription", () => {
|
||||||
it(`Produces a sentence for valid cron`, () => {
|
it(`Produces a sentence for valid cron`, () => {
|
||||||
expect(getCronDescription("0 * * * *")).toEqual(
|
expect(getCronDescription("0 * * * *")).toEqual("Runs every hour");
|
||||||
"Runs every hour\n\n" +
|
|
||||||
"Actions schedules run at most every 5 minutes. [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`Returns nothing for invalid cron`, () => {
|
it(`Returns nothing for invalid cron`, () => {
|
||||||
expect(getCronDescription("* * * * * *")).toBeUndefined();
|
expect(getCronDescription("* * * * * *")).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hasCronIntervalLessThan5Minutes", () => {
|
||||||
|
it("returns true for step expressions with interval < 5 min", () => {
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("*/1 * * * *")).toBe(true);
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("*/4 * * * *")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for step expressions with interval >= 5 min", () => {
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("*/5 * * * *")).toBe(false);
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("*/15 * * * *")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for comma-separated values with gap < 5 min", () => {
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("0,2,4 * * * *")).toBe(true);
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("0,10,12 * * * *")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for comma-separated values with gap >= 5 min", () => {
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("0,10,20 * * * *")).toBe(false);
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("0,30 * * * *")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for comma-separated values with wrap-around gap < 5 min", () => {
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("0,58 * * * *")).toBe(true);
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("2,59 * * * *")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for * (every minute)", () => {
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("* * * * *")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for range expressions (runs every minute in range)", () => {
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("0-4 * * * *")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for single value (hourly)", () => {
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("0 * * * *")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for invalid cron", () => {
|
||||||
|
expect(hasCronIntervalLessThan5Minutes("invalid")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,78 @@ type Range = {
|
|||||||
names?: Record<string, number>;
|
names?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a cron expression specifies an interval shorter than 5 minutes.
|
||||||
|
* GitHub Actions schedules run at most every 5 minutes, so intervals < 5 min won't work as expected.
|
||||||
|
*/
|
||||||
|
export function hasCronIntervalLessThan5Minutes(cron: string): boolean {
|
||||||
|
if (!isValidCron(cron)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = cron.split(/ +/);
|
||||||
|
const minutePart = parts[0];
|
||||||
|
|
||||||
|
// Parse the minute field to determine the effective interval
|
||||||
|
return getMinuteInterval(minutePart) < 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the minimum interval in minutes between cron executions based on the minute field.
|
||||||
|
* Returns 60 if there's only one execution per hour, otherwise returns the minimum gap.
|
||||||
|
*/
|
||||||
|
function getMinuteInterval(minutePart: string): number {
|
||||||
|
// Handle step expressions like */1, */3, 0-59/2
|
||||||
|
if (minutePart.includes("/")) {
|
||||||
|
const [, step] = minutePart.split("/");
|
||||||
|
const stepNum = parseInt(step, 10);
|
||||||
|
if (!isNaN(stepNum) && stepNum > 0) {
|
||||||
|
return stepNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle comma-separated values like 0,2,4 or 0,1,5,10
|
||||||
|
if (minutePart.includes(",")) {
|
||||||
|
const values = minutePart
|
||||||
|
.split(",")
|
||||||
|
.map(v => parseInt(v, 10))
|
||||||
|
.filter(n => !isNaN(n))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
if (values.length >= 2) {
|
||||||
|
let minGap = 60;
|
||||||
|
for (let i = 1; i < values.length; i++) {
|
||||||
|
const gap = values[i] - values[i - 1];
|
||||||
|
if (gap < minGap) {
|
||||||
|
minGap = gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check wrap-around gap from last minute to first minute of next hour
|
||||||
|
const wrapGap = values[0] + 60 - values[values.length - 1];
|
||||||
|
if (wrapGap < minGap) {
|
||||||
|
minGap = wrapGap;
|
||||||
|
}
|
||||||
|
return minGap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle range expressions like 0-4 (runs every minute from 0-4)
|
||||||
|
if (minutePart.includes("-") && !minutePart.includes("/")) {
|
||||||
|
const [start, end] = minutePart.split("-").map(v => parseInt(v, 10));
|
||||||
|
if (!isNaN(start) && !isNaN(end) && end > start) {
|
||||||
|
// A range without step means every minute in that range
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// * means every minute
|
||||||
|
if (minutePart === "*") {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single value or unrecognized pattern - assume hourly (60 min interval)
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
|
||||||
export function isValidCron(cron: string): boolean {
|
export function isValidCron(cron: string): boolean {
|
||||||
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
||||||
|
|
||||||
@@ -46,11 +118,7 @@ export function getCronDescription(cronspec: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make first character lowercase
|
// Make first character lowercase
|
||||||
let result = "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
|
return "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
|
||||||
result +=
|
|
||||||
"\n\nActions schedules run at most every 5 minutes." +
|
|
||||||
" [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)";
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateCronPart(value: string, range: Range, allowSeparators = true): boolean {
|
function validateCronPart(value: string, range: Range, allowSeparators = true): boolean {
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ function convertSchedule(context: TemplateContext, token: SequenceToken): Schedu
|
|||||||
const cron = schedule.value.assertString(`schedule cron`);
|
const cron = schedule.value.assertString(`schedule cron`);
|
||||||
// Validate the cron string
|
// Validate the cron string
|
||||||
if (!isValidCron(cron.value)) {
|
if (!isValidCron(cron.value)) {
|
||||||
context.error(cron, "Invalid cron string");
|
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
|
||||||
}
|
}
|
||||||
result.push({cron: cron.value});
|
result.push({cron: cron.value});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test ensures that activity types in workflow-v1.0.json stay in sync with
|
||||||
|
* the webhooks.json file from the languageservice package.
|
||||||
|
*
|
||||||
|
* When this test fails, it means new activity types were added to webhooks.json
|
||||||
|
* that need to be handled. See docs/json-data-files.md for detailed instructions.
|
||||||
|
*
|
||||||
|
* Quick reference for fixing failures:
|
||||||
|
* 1. Check https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||||
|
* Find the event and look at its "Activity types" table to see if the type is a valid workflow trigger.
|
||||||
|
* 2. If the activity type IS a valid workflow trigger:
|
||||||
|
* → Add it to the corresponding *-activity-type definition in workflow-v1.0.json
|
||||||
|
* 3. If the activity type is webhook-only (not in workflow docs):
|
||||||
|
* → Add it to the WEBHOOK_ONLY list below
|
||||||
|
* 4. If there's a naming difference between webhook and schema:
|
||||||
|
* → Add it to the NAME_MAPPINGS list below
|
||||||
|
* 5. If the schema has a type not in webhooks.json:
|
||||||
|
* → Add it to the SCHEMA_ONLY list below
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("schema-sync", () => {
|
||||||
|
// Activity types that exist in webhooks.json but are intentionally NOT
|
||||||
|
// supported as workflow triggers. These will be ignored when checking
|
||||||
|
// webhooks → schema direction.
|
||||||
|
const WEBHOOK_ONLY: Record<string, string[]> = {
|
||||||
|
// check_suite: requested and rerequested are webhook-only, not valid workflow triggers
|
||||||
|
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#check_suite
|
||||||
|
check_suite: ["requested", "rerequested"],
|
||||||
|
|
||||||
|
// registry_package: "default" is a webhook concept, not a workflow trigger type
|
||||||
|
registry_package: ["default"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Activity types that exist in workflow schema but are intentionally NOT
|
||||||
|
// in webhooks.json (schema-only types). These will be ignored when checking
|
||||||
|
// schema → webhooks direction.
|
||||||
|
const SCHEMA_ONLY: Record<string, string[]> = {
|
||||||
|
// registry_package: "updated" is a valid workflow trigger per GitHub docs
|
||||||
|
// but doesn't exist in webhooks.json (webhooks only has "published" and "default")
|
||||||
|
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#registry_package
|
||||||
|
registry_package: ["updated"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Known naming differences between webhooks.json and workflow-v1.0.json.
|
||||||
|
// Key: event name, Value: { webhook: "webhookName", schema: "schemaName" }
|
||||||
|
// These are treated as equivalent when comparing in both directions.
|
||||||
|
const NAME_MAPPINGS: Record<string, Array<{webhook: string; schema: string}>> = {
|
||||||
|
// project_column: webhooks.json uses "edited" but workflow triggers use "updated"
|
||||||
|
// This is a known naming difference - they represent the same action
|
||||||
|
project_column: [{webhook: "edited", schema: "updated"}]
|
||||||
|
};
|
||||||
|
|
||||||
|
it("activity types in workflow-v1.0.json match webhooks.json", () => {
|
||||||
|
// Load webhooks.json (relative path from the test runner CWD which is the package root)
|
||||||
|
const webhooksPath = "../languageservice/src/context-providers/events/webhooks.json";
|
||||||
|
const webhooks = JSON.parse(fs.readFileSync(webhooksPath, "utf-8")) as Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
// Load workflow-v1.0.json
|
||||||
|
const schemaPath = "./src/workflow-v1.0.json";
|
||||||
|
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as {
|
||||||
|
definitions: Record<string, {"allowed-values"?: string[]; description?: string}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mismatches: string[] = [];
|
||||||
|
|
||||||
|
// Build mapping helpers for each event
|
||||||
|
const getWebhookToSchemaMapping = (eventName: string): Map<string, string> => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const mapping of NAME_MAPPINGS[eventName] || []) {
|
||||||
|
map.set(mapping.webhook, mapping.schema);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSchemaToWebhookMapping = (eventName: string): Map<string, string> => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const mapping of NAME_MAPPINGS[eventName] || []) {
|
||||||
|
map.set(mapping.schema, mapping.webhook);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check both directions for each event
|
||||||
|
for (const [eventName, eventData] of Object.entries(webhooks)) {
|
||||||
|
const webhookTypes = Object.keys(eventData);
|
||||||
|
if (webhookTypes.length === 0) continue;
|
||||||
|
|
||||||
|
const schemaTypeName = `${eventName.replace(/_/g, "-")}-activity-type`;
|
||||||
|
const schemaDef = schema.definitions[schemaTypeName];
|
||||||
|
|
||||||
|
// If there's no activity type definition in the schema, this event
|
||||||
|
// doesn't support activity types in workflows (e.g., push, pull)
|
||||||
|
if (!schemaDef || !schemaDef["allowed-values"]) continue;
|
||||||
|
|
||||||
|
const schemaTypes = new Set(schemaDef["allowed-values"]);
|
||||||
|
const webhookOnly = new Set(WEBHOOK_ONLY[eventName] || []);
|
||||||
|
const schemaOnly = new Set(SCHEMA_ONLY[eventName] || []);
|
||||||
|
const webhookToSchema = getWebhookToSchemaMapping(eventName);
|
||||||
|
const schemaToWebhook = getSchemaToWebhookMapping(eventName);
|
||||||
|
|
||||||
|
// Direction 1: webhooks → schema
|
||||||
|
// Check that each webhook type exists in schema (or has a mapping, or is webhook-only)
|
||||||
|
for (const webhookType of webhookTypes) {
|
||||||
|
if (webhookOnly.has(webhookType)) continue;
|
||||||
|
|
||||||
|
const mappedSchemaType = webhookToSchema.get(webhookType);
|
||||||
|
if (mappedSchemaType) {
|
||||||
|
// Has a mapping - check the mapped name exists in schema
|
||||||
|
if (!schemaTypes.has(mappedSchemaType)) {
|
||||||
|
mismatches.push(
|
||||||
|
`Event "${eventName}": webhook type "${webhookType}" maps to "${mappedSchemaType}" but "${mappedSchemaType}" not found in schema`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No mapping - check the type exists directly
|
||||||
|
if (!schemaTypes.has(webhookType)) {
|
||||||
|
mismatches.push(
|
||||||
|
`Event "${eventName}": missing activity type "${webhookType}" in workflow-v1.0.json (exists in webhooks.json)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction 2: schema → webhooks
|
||||||
|
// Check that each schema type exists in webhooks (or has a mapping, or is schema-only)
|
||||||
|
const webhookTypesSet = new Set(webhookTypes);
|
||||||
|
for (const schemaType of schemaTypes) {
|
||||||
|
if (schemaOnly.has(schemaType)) continue;
|
||||||
|
|
||||||
|
const mappedWebhookType = schemaToWebhook.get(schemaType);
|
||||||
|
if (mappedWebhookType) {
|
||||||
|
// Has a mapping - check the mapped name exists in webhooks
|
||||||
|
if (!webhookTypesSet.has(mappedWebhookType)) {
|
||||||
|
mismatches.push(
|
||||||
|
`Event "${eventName}": schema type "${schemaType}" maps to "${mappedWebhookType}" but "${mappedWebhookType}" not found in webhooks.json`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No mapping - check the type exists directly
|
||||||
|
if (!webhookTypesSet.has(schemaType)) {
|
||||||
|
mismatches.push(
|
||||||
|
`Event "${eventName}": extra activity type "${schemaType}" in workflow-v1.0.json (not in webhooks.json)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the description mentions all allowed values
|
||||||
|
const activityDefName = `${eventName.replace(/_/g, "-")}-activity`;
|
||||||
|
const activityDef = schema.definitions[activityDefName];
|
||||||
|
if (activityDef?.description) {
|
||||||
|
for (const schemaType of schemaTypes) {
|
||||||
|
if (!activityDef.description.includes(`\`${schemaType}\``)) {
|
||||||
|
mismatches.push(
|
||||||
|
`Event "${eventName}": description in "${activityDefName}" is missing activity type \`${schemaType}\``
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mismatches.length > 0) {
|
||||||
|
const errorMessage = [
|
||||||
|
"Activity type mismatches found between webhooks.json and workflow-v1.0.json:",
|
||||||
|
"",
|
||||||
|
...mismatches,
|
||||||
|
"",
|
||||||
|
"To fix these mismatches:",
|
||||||
|
"1. Check GitHub docs: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows",
|
||||||
|
"2. Verify the activity type is valid for workflow triggers",
|
||||||
|
"3. Update the *-activity-type definition in workflow-parser/src/workflow-v1.0.json",
|
||||||
|
"4. Update the description to list all supported activity types",
|
||||||
|
"5. If there's a naming difference, add it to NAME_MAPPINGS in schema-sync.test.ts",
|
||||||
|
"6. If the type is webhook-only, add it to WEBHOOK_ONLY",
|
||||||
|
"7. If the type is schema-only, add it to SCHEMA_ONLY"
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -856,7 +856,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pull-request-activity": {
|
"pull-request-activity": {
|
||||||
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `ready_for_review`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `locked`, `unlocked`, `enqueued`, `dequeued`, `milestoned`, `demilestoned`, `ready_for_review`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
||||||
"one-of": [
|
"one-of": [
|
||||||
"pull-request-activity-type",
|
"pull-request-activity-type",
|
||||||
"pull-request-activity-types"
|
"pull-request-activity-types"
|
||||||
@@ -879,9 +879,13 @@
|
|||||||
"reopened",
|
"reopened",
|
||||||
"synchronize",
|
"synchronize",
|
||||||
"converted_to_draft",
|
"converted_to_draft",
|
||||||
"ready_for_review",
|
|
||||||
"locked",
|
"locked",
|
||||||
"unlocked",
|
"unlocked",
|
||||||
|
"enqueued",
|
||||||
|
"dequeued",
|
||||||
|
"milestoned",
|
||||||
|
"demilestoned",
|
||||||
|
"ready_for_review",
|
||||||
"review_requested",
|
"review_requested",
|
||||||
"review_request_removed",
|
"review_request_removed",
|
||||||
"auto_merge_enabled",
|
"auto_merge_enabled",
|
||||||
@@ -1004,7 +1008,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pull-request-target-activity": {
|
"pull-request-target-activity": {
|
||||||
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `ready_for_review`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `locked`, `unlocked`, `enqueued`, `dequeued`, `milestoned`, `demilestoned`, `ready_for_review`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
||||||
"one-of": [
|
"one-of": [
|
||||||
"pull-request-target-activity-type",
|
"pull-request-target-activity-type",
|
||||||
"pull-request-target-activity-types"
|
"pull-request-target-activity-types"
|
||||||
@@ -1027,9 +1031,13 @@
|
|||||||
"reopened",
|
"reopened",
|
||||||
"synchronize",
|
"synchronize",
|
||||||
"converted_to_draft",
|
"converted_to_draft",
|
||||||
"ready_for_review",
|
|
||||||
"locked",
|
"locked",
|
||||||
"unlocked",
|
"unlocked",
|
||||||
|
"enqueued",
|
||||||
|
"dequeued",
|
||||||
|
"milestoned",
|
||||||
|
"demilestoned",
|
||||||
|
"ready_for_review",
|
||||||
"review_requested",
|
"review_requested",
|
||||||
"review_request_removed",
|
"review_request_removed",
|
||||||
"auto_merge_enabled",
|
"auto_merge_enabled",
|
||||||
@@ -1539,7 +1547,7 @@
|
|||||||
},
|
},
|
||||||
"default": "workflow-dispatch-input-default",
|
"default": "workflow-dispatch-input-default",
|
||||||
"options": {
|
"options": {
|
||||||
"type": "sequence-of-non-empty-string",
|
"type": "sequence-of-string",
|
||||||
"description": "The options of the dropdown list, if the type is a choice."
|
"description": "The options of the dropdown list, if the type is a choice."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2419,6 +2427,11 @@
|
|||||||
"item-type": "non-empty-string"
|
"item-type": "non-empty-string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sequence-of-string": {
|
||||||
|
"sequence": {
|
||||||
|
"item-type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
"boolean-needs-context": {
|
"boolean-needs-context": {
|
||||||
"context": [
|
"context": [
|
||||||
"github",
|
"github",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {JSONObjectReader} from "../templates/json-object-reader";
|
import {JSONObjectReader} from "../templates/json-object-reader";
|
||||||
import {TemplateSchema} from "../templates/schema";
|
import {TemplateSchema} from "../templates/schema";
|
||||||
import WorkflowSchema from "../workflow-v1.0.json";
|
import WorkflowSchema from "../workflow-v1.0.min.json";
|
||||||
|
|
||||||
let schema: TemplateSchema;
|
let schema: TemplateSchema;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
import {isCollection, isDocument, isMap, isPair, isScalar, isSeq, LineCounter, parseDocument, Scalar} from "yaml";
|
import {
|
||||||
|
isAlias,
|
||||||
|
isCollection,
|
||||||
|
isDocument,
|
||||||
|
isMap,
|
||||||
|
isPair,
|
||||||
|
isScalar,
|
||||||
|
isSeq,
|
||||||
|
LineCounter,
|
||||||
|
parseDocument,
|
||||||
|
Scalar
|
||||||
|
} from "yaml";
|
||||||
|
import type {Document} from "yaml";
|
||||||
import type {LinePos} from "yaml/dist/errors";
|
import type {LinePos} from "yaml/dist/errors";
|
||||||
import type {NodeBase} from "yaml/dist/nodes/Node";
|
import type {NodeBase} from "yaml/dist/nodes/Node";
|
||||||
import {ObjectReader} from "../templates/object-reader";
|
import {ObjectReader} from "../templates/object-reader";
|
||||||
@@ -22,30 +34,31 @@ export type YamlError = {
|
|||||||
export class YamlObjectReader implements ObjectReader {
|
export class YamlObjectReader implements ObjectReader {
|
||||||
private readonly _generator: Generator<ParseEvent>;
|
private readonly _generator: Generator<ParseEvent>;
|
||||||
private _current!: IteratorResult<ParseEvent>;
|
private _current!: IteratorResult<ParseEvent>;
|
||||||
|
private readonly doc: Document;
|
||||||
private fileId?: number;
|
private fileId?: number;
|
||||||
private lineCounter = new LineCounter();
|
private lineCounter = new LineCounter();
|
||||||
|
|
||||||
public errors: YamlError[] = [];
|
public errors: YamlError[] = [];
|
||||||
|
|
||||||
constructor(fileId: number | undefined, content: string) {
|
constructor(fileId: number | undefined, content: string) {
|
||||||
const doc = parseDocument(content, {
|
this.doc = parseDocument(content, {
|
||||||
lineCounter: this.lineCounter,
|
lineCounter: this.lineCounter,
|
||||||
keepSourceTokens: true,
|
keepSourceTokens: true,
|
||||||
uniqueKeys: false // Uniqueness is validated by the template reader
|
uniqueKeys: false // Uniqueness is validated by the template reader
|
||||||
});
|
});
|
||||||
for (const err of doc.errors) {
|
for (const err of this.doc.errors) {
|
||||||
this.errors.push({message: err.message, range: rangeFromLinePos(err.linePos)});
|
this.errors.push({message: err.message, range: rangeFromLinePos(err.linePos)});
|
||||||
}
|
}
|
||||||
this._generator = this.getNodes(doc);
|
this._generator = this.getNodes(this.doc, new Set());
|
||||||
this.fileId = fileId;
|
this.fileId = fileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private *getNodes(node: unknown): Generator<ParseEvent, void> {
|
private *getNodes(node: unknown, aliasResolutionStack: Set<unknown>): Generator<ParseEvent, void> {
|
||||||
let range = this.getRange(node as NodeBase | undefined);
|
let range = this.getRange(node as NodeBase | undefined);
|
||||||
|
|
||||||
if (isDocument(node)) {
|
if (isDocument(node)) {
|
||||||
yield new ParseEvent(EventType.DocumentStart);
|
yield new ParseEvent(EventType.DocumentStart);
|
||||||
for (const item of this.getNodes(node.contents)) {
|
for (const item of this.getNodes(node.contents, new Set())) {
|
||||||
yield item;
|
yield item;
|
||||||
}
|
}
|
||||||
yield new ParseEvent(EventType.DocumentEnd);
|
yield new ParseEvent(EventType.DocumentEnd);
|
||||||
@@ -59,7 +72,7 @@ export class YamlObjectReader implements ObjectReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of node.items) {
|
for (const item of node.items) {
|
||||||
for (const child of this.getNodes(item)) {
|
for (const child of this.getNodes(item, aliasResolutionStack)) {
|
||||||
yield child;
|
yield child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,12 +87,32 @@ export class YamlObjectReader implements ObjectReader {
|
|||||||
yield new ParseEvent(EventType.Literal, YamlObjectReader.getLiteralToken(this.fileId, range, node));
|
yield new ParseEvent(EventType.Literal, YamlObjectReader.getLiteralToken(this.fileId, range, node));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle YAML aliases - resolve to the anchored value
|
||||||
|
if (isAlias(node)) {
|
||||||
|
const resolved = node.resolve(this.doc);
|
||||||
|
if (resolved) {
|
||||||
|
// Prevent infinite recursion from circular aliases
|
||||||
|
if (aliasResolutionStack.has(resolved)) {
|
||||||
|
// Silently ignore circular reference - the missing content will cause
|
||||||
|
// downstream validation errors which is acceptable for this edge case
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Track this node in the alias resolution stack
|
||||||
|
const newStack = new Set(aliasResolutionStack);
|
||||||
|
newStack.add(resolved);
|
||||||
|
// Yield the resolved node's contents
|
||||||
|
yield* this.getNodes(resolved, newStack);
|
||||||
|
}
|
||||||
|
// If unresolved, the yaml library already reports an error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isPair(node)) {
|
if (isPair(node)) {
|
||||||
const scalarKey = node.key as Scalar;
|
const scalarKey = node.key as Scalar;
|
||||||
range = this.getRange(scalarKey);
|
range = this.getRange(scalarKey);
|
||||||
const key = scalarKey.value as string;
|
const key = scalarKey.value as string;
|
||||||
yield new ParseEvent(EventType.Literal, new StringToken(this.fileId, range, key, undefined));
|
yield new ParseEvent(EventType.Literal, new StringToken(this.fileId, range, key, undefined));
|
||||||
for (const child of this.getNodes(node.value)) {
|
for (const child of this.getNodes(node.value, aliasResolutionStack)) {
|
||||||
yield child;
|
yield child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ on:
|
|||||||
- unassigned
|
- unassigned
|
||||||
- labeled
|
- labeled
|
||||||
- unlabeled
|
- unlabeled
|
||||||
|
- milestoned
|
||||||
|
- demilestoned
|
||||||
- opened
|
- opened
|
||||||
- edited
|
- edited
|
||||||
- closed
|
- closed
|
||||||
@@ -129,6 +131,8 @@ on:
|
|||||||
- ready_for_review
|
- ready_for_review
|
||||||
- locked
|
- locked
|
||||||
- unlocked
|
- unlocked
|
||||||
|
- enqueued
|
||||||
|
- dequeued
|
||||||
- review_requested
|
- review_requested
|
||||||
- review_request_removed
|
- review_request_removed
|
||||||
- auto_merge_enabled
|
- auto_merge_enabled
|
||||||
@@ -160,6 +164,8 @@ on:
|
|||||||
- unassigned
|
- unassigned
|
||||||
- labeled
|
- labeled
|
||||||
- unlabeled
|
- unlabeled
|
||||||
|
- milestoned
|
||||||
|
- demilestoned
|
||||||
- opened
|
- opened
|
||||||
- edited
|
- edited
|
||||||
- closed
|
- closed
|
||||||
@@ -169,6 +175,8 @@ on:
|
|||||||
- ready_for_review
|
- ready_for_review
|
||||||
- locked
|
- locked
|
||||||
- unlocked
|
- unlocked
|
||||||
|
- enqueued
|
||||||
|
- dequeued
|
||||||
- review_requested
|
- review_requested
|
||||||
- review_request_removed
|
- review_request_removed
|
||||||
- auto_merge_enabled
|
- auto_merge_enabled
|
||||||
@@ -386,6 +394,8 @@ jobs:
|
|||||||
"unassigned",
|
"unassigned",
|
||||||
"labeled",
|
"labeled",
|
||||||
"unlabeled",
|
"unlabeled",
|
||||||
|
"milestoned",
|
||||||
|
"demilestoned",
|
||||||
"opened",
|
"opened",
|
||||||
"edited",
|
"edited",
|
||||||
"closed",
|
"closed",
|
||||||
@@ -395,6 +405,8 @@ jobs:
|
|||||||
"ready_for_review",
|
"ready_for_review",
|
||||||
"locked",
|
"locked",
|
||||||
"unlocked",
|
"unlocked",
|
||||||
|
"enqueued",
|
||||||
|
"dequeued",
|
||||||
"review_requested",
|
"review_requested",
|
||||||
"review_request_removed",
|
"review_request_removed",
|
||||||
"auto_merge_enabled",
|
"auto_merge_enabled",
|
||||||
@@ -441,6 +453,8 @@ jobs:
|
|||||||
"unassigned",
|
"unassigned",
|
||||||
"labeled",
|
"labeled",
|
||||||
"unlabeled",
|
"unlabeled",
|
||||||
|
"milestoned",
|
||||||
|
"demilestoned",
|
||||||
"opened",
|
"opened",
|
||||||
"edited",
|
"edited",
|
||||||
"closed",
|
"closed",
|
||||||
@@ -450,6 +464,8 @@ jobs:
|
|||||||
"ready_for_review",
|
"ready_for_review",
|
||||||
"locked",
|
"locked",
|
||||||
"unlocked",
|
"unlocked",
|
||||||
|
"enqueued",
|
||||||
|
"dequeued",
|
||||||
"review_requested",
|
"review_requested",
|
||||||
"review_request_removed",
|
"review_request_removed",
|
||||||
"auto_merge_enabled",
|
"auto_merge_enabled",
|
||||||
|
|||||||
Reference in New Issue
Block a user