Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot] 7b9adb106e Release extension version 0.3.23 (#230)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-05 10:38:55 -06:00
eric sciple 576402fc01 Optimize JSON data files to reduce bundle size by 90% (#229) 2025-12-05 10:27:19 -06:00
github-actions[bot] 22c36bc946 Release extension version 0.3.22 (#228)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-04 13:36:13 -06:00
eric sciple 4dd678cf30 Improve cron schedule warning message (#227) 2025-12-04 13:31:20 -06:00
github-actions[bot] dfb411f71e Release extension version 0.3.21 (#226)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-12-04 11:48:14 -06:00
eric sciple dec597b0db Improve cron schedule validation and diagnostics (#224) 2025-12-04 11:25:15 -06:00
eric sciple bd7e5f0b70 Fix npm audit vulnerabilities (#222) 2025-12-03 11:57:43 -06:00
eric sciple 37ba6ab105 Fix misleading error for malformed local workflow paths (#221) 2025-12-03 10:40:31 -06:00
eric sciple 216fcbb8c4 Add uses format validation for step and job-level workflows (#220) 2025-12-03 09:44:36 -06:00
34 changed files with 28211 additions and 126864 deletions
+33
View File
@@ -27,3 +27,36 @@ jobs:
- run: npm run build -ws
- run: npm run lint -ws
- run: npm test -ws
check-generated:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 16.15
uses: actions/setup-node@v4
with:
node-version: 16.15
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
+9 -1
View File
@@ -2,4 +2,12 @@
*/dist
lerna-debug.log
node_modules
.DS_Store
.DS_Store
# Minified JSON (generated at build time)
*.min.json
# Intermediate JSON for size comparison (generated by update-webhooks --all)
*.all.json
*.drop.json
*.strip.json
+4
View File
@@ -8,6 +8,10 @@ This repository contains multiple npm packages for working with GitHub Actions w
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
- [browser-playground](./browser-playground) - Browser-based playground for the language service
## Documentation
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
### Note
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
+150
View File
@@ -0,0 +1,150 @@
# 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.20",
"version": "0.3.23",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.20",
"version": "0.3.23",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -43,8 +43,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/languageservice": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@actions/languageservice": "^0.3.23",
"@actions/workflow-parser": "^0.3.23",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.20",
"version": "0.3.23",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -37,15 +37,18 @@
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
"prebuild": "npm run minify-json",
"prepublishOnly": "npm run build && npm run test",
"pretest": "npm run minify-json",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"update-webhooks": "ts-node-esm script/webhooks/index.ts",
"update-webhooks": "npx tsx script/webhooks/index.ts",
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@actions/expressions": "^0.3.23",
"@actions/workflow-parser": "^0.3.23",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
+273 -2
View File
@@ -7,6 +7,185 @@ const schema = schemaImport as any;
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
const ALL_OUTPUT_PATH = "./src/context-providers/events/webhooks.all.json";
const ALL_OBJECTS_PATH = "./src/context-providers/events/objects.all.json";
const DROP_OUTPUT_PATH = "./src/context-providers/events/webhooks.drop.json";
const DROP_OBJECTS_PATH = "./src/context-providers/events/objects.drop.json";
const STRIP_OUTPUT_PATH = "./src/context-providers/events/webhooks.strip.json";
const STRIP_OBJECTS_PATH = "./src/context-providers/events/objects.strip.json";
// Parse --all flag
const generateAll = process.argv.includes("--all");
// Events to drop - not valid workflow triggers (GitHub App or API-only events)
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const DROPPED_EVENTS = new Set([
"branch_protection_configuration",
"code_scanning_alert",
"commit_comment",
"custom_property",
"custom_property_values",
"dependabot_alert",
"deploy_key",
"github_app_authorization",
"installation",
"installation_repositories",
"installation_target",
"marketplace_purchase",
"member",
"membership",
"merge_group",
"meta",
"org_block",
"organization",
"package",
"personal_access_token_request",
"ping",
"repository",
"repository_advisory",
"repository_ruleset",
"secret_scanning_alert",
"secret_scanning_alert_location",
"security_advisory",
"security_and_analysis",
"sponsorship",
"star",
"team",
"team_add"
]);
// Events to keep - valid workflow triggers
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const KEPT_EVENTS = new Set([
"branch_protection_rule",
"check_run",
"check_suite",
"create",
"delete",
"deployment",
"deployment_status",
"discussion",
"discussion_comment",
"fork",
"gollum",
"issue_comment",
"issues",
"label",
"milestone",
"page_build",
"project",
"project_card",
"project_column",
"projects_v2",
"projects_v2_item",
"public",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
"pull_request_review_thread",
"push",
"registry_package",
"release",
"repository_dispatch",
"repository_import",
"repository_vulnerability_alert",
"status",
"watch",
"workflow_dispatch",
"workflow_job",
"workflow_run"
]);
/**
* Fields to strip from the JSON data.
*
* EVENT_ACTION_FIELDS: stripped from each event action object (top level only)
* Example event action object before stripping:
* {
* "description": "This event is triggered when...", // <-- stripped
* "summary": "A brief summary", // <-- stripped
* "availability": ["repository"], // <-- stripped
* "category": "issues", // <-- stripped
* "action": "opened", // kept
* "bodyParameters": [...] // kept
* }
*
* BODY_PARAM_FIELDS: stripped from every bodyParameters object, recursively through childParamsGroups
* Example bodyParameter object before stripping:
* {
* "type": "object", // <-- stripped
* "name": "changes", // kept (used for property names)
* "in": "body", // <-- stripped
* "description": "The changes that were made.", // kept (used for hover docs)
* "isRequired": true, // <-- stripped
* "enum": ["a", "b"], // <-- stripped
* "default": "a", // <-- stripped
* "childParamsGroups": [ // kept (used for nested properties)
* {
* "type": "string", // <-- stripped (recursive)
* "name": "from", // kept
* "isRequired": true // <-- stripped (recursive)
* }
* ]
* }
*/
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category"];
const BODY_PARAM_FIELDS = ["type", "in", "isRequired", "enum", "default"];
/**
* Strip fields from a bodyParameter object and recursively from childParamsGroups.
*/
function stripBodyParam(param: any): any {
if (typeof param !== "object" || param === null) {
return param;
}
const result: any = {};
for (const [key, value] of Object.entries(param)) {
if (BODY_PARAM_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "childParamsGroups" && Array.isArray(value)) {
result[key] = value.map(stripBodyParam);
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from event action data.
*/
function stripEventActionFields(action: any): any {
const result: any = {};
for (const [key, value] of Object.entries(action)) {
if (EVENT_ACTION_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "bodyParameters" && Array.isArray(value)) {
result[key] = value.map((p: any) => (typeof p === "number" ? p : stripBodyParam(p)));
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from all webhooks.
* Structure: { eventName: { actionName: { ...fields } } }
*/
function stripFields(webhooks: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
const result: Record<string, Record<string, any>> = {};
for (const [eventName, actions] of Object.entries(webhooks)) {
result[eventName] = {};
for (const [actionName, actionData] of Object.entries(actions)) {
result[eventName][actionName] = stripEventActionFields(actionData);
}
}
return result;
}
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
if (!rawWebhooks) {
@@ -20,11 +199,51 @@ for (const webhook of Object.values(rawWebhooks)) {
await Promise.all(webhooks.map(webhook => webhook.process()));
// Check for unknown events (not in DROPPED_EVENTS or KEPT_EVENTS)
const unknownEvents: string[] = [];
for (const webhook of webhooks) {
if (!DROPPED_EVENTS.has(webhook.category) && !KEPT_EVENTS.has(webhook.category)) {
if (!unknownEvents.includes(webhook.category)) {
unknownEvents.push(webhook.category);
}
}
}
if (unknownEvents.length > 0) {
console.error("");
console.error("══════════════════════════════════════════════════════════════════");
console.error("ERROR: New webhook event(s) detected!");
console.error("══════════════════════════════════════════════════════════════════");
console.error("");
console.error("The following events are not categorized:");
for (const event of unknownEvents.sort()) {
console.error(` - ${event}`);
}
console.error("");
console.error("Action required:");
console.error(" 1. Check if the event is a valid workflow trigger:");
console.error(
" https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows"
);
console.error("");
console.error(" 2. Add the event to DROPPED_EVENTS or KEPT_EVENTS in:");
console.error(" languageservice/script/webhooks/index.ts");
console.error("");
console.error(" 3. See docs/json-data-files.md for more details.");
console.error("");
process.exit(1);
}
// The category is the name of the webhook
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
for (const webhook of webhooks) {
if (!webhook.action) webhook.action = "default";
// Drop unused events
if (DROPPED_EVENTS.has(webhook.category)) {
continue;
}
if (categorizedWebhooks[webhook.category]) {
categorizedWebhooks[webhook.category][webhook.action] = webhook;
} else {
@@ -33,7 +252,59 @@ for (const webhook of webhooks) {
}
}
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
// Strip fields before deduplication
const strippedWebhooks = stripFields(categorizedWebhooks);
// Deduplicate after dropping and stripping
const objectsArray = deduplicateWebhooks(strippedWebhooks);
// Write optimized output
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, null, 2));
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(strippedWebhooks).length} events)`);
console.log(`Wrote ${OBJECTS_PATH} (${objectsArray.length} objects)`);
// Optionally generate intermediate versions for size comparison
if (generateAll) {
// Helper to deep clone
function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
// Build full webhooks (no drop, no strip) from fresh data
const fullWebhooks: Record<string, Record<string, any>> = {};
for (const webhook of webhooks) {
const w = clone(webhook);
if (!w.action) w.action = "default";
fullWebhooks[w.category] ||= {};
fullWebhooks[w.category][w.action] = w;
}
// Generate all version (no drop, no strip)
const allWebhooks = clone(fullWebhooks);
const allObjects = deduplicateWebhooks(allWebhooks);
await fs.writeFile(ALL_OUTPUT_PATH, JSON.stringify(allWebhooks, null, 2));
await fs.writeFile(ALL_OBJECTS_PATH, JSON.stringify(allObjects, null, 2));
console.log(`Wrote ${ALL_OUTPUT_PATH} (${Object.keys(allWebhooks).length} events)`);
console.log(`Wrote ${ALL_OBJECTS_PATH} (${allObjects.length} objects)`);
// Generate drop-only version (drop events, no strip)
const dropWebhooks = clone(fullWebhooks);
for (const event of DROPPED_EVENTS) {
delete dropWebhooks[event];
}
const dropObjects = deduplicateWebhooks(dropWebhooks);
await fs.writeFile(DROP_OUTPUT_PATH, JSON.stringify(dropWebhooks, null, 2));
await fs.writeFile(DROP_OBJECTS_PATH, JSON.stringify(dropObjects, null, 2));
console.log(`Wrote ${DROP_OUTPUT_PATH} (${Object.keys(dropWebhooks).length} events)`);
console.log(`Wrote ${DROP_OBJECTS_PATH} (${dropObjects.length} objects)`);
// Generate strip-only version (strip fields, no drop)
const stripWebhooks = stripFields(clone(fullWebhooks));
const stripObjects = deduplicateWebhooks(stripWebhooks);
await fs.writeFile(STRIP_OUTPUT_PATH, JSON.stringify(stripWebhooks, null, 2));
await fs.writeFile(STRIP_OBJECTS_PATH, JSON.stringify(stripObjects, null, 2));
console.log(`Wrote ${STRIP_OUTPUT_PATH} (${Object.keys(stripWebhooks).length} events)`);
console.log(`Wrote ${STRIP_OBJECTS_PATH} (${stripObjects.length} objects)`);
}
@@ -1268,7 +1268,7 @@ jobs:
on: push
jobs:
a:
uses: ./reusable-workflow-with-outputs.yaml
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
b:
needs: [a]
runs-on: ubuntu-latest
@@ -21,7 +21,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
|
`;
@@ -49,7 +49,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
username: monalisa
|
@@ -74,7 +74,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets:
|
`;
@@ -102,7 +102,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets: |
`;
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
@@ -117,7 +117,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets:
envPAT: "myPAT"
|
@@ -1,4 +1,4 @@
import descriptions from "./descriptions.json";
import descriptions from "./descriptions.min.json";
export const RootContext = "root";
const FunctionContext = "functions";
@@ -0,0 +1,102 @@
import {DescriptionDictionary} from "@actions/expressions";
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads";
describe("eventPayloads", () => {
describe("getSupportedEventTypes", () => {
it("returns action types for push event", () => {
const types = getSupportedEventTypes("push");
expect(types).toContain("default");
});
it("returns action types for issues event", () => {
const types = getSupportedEventTypes("issues");
expect(types.length).toBeGreaterThan(1);
expect(types).toContain("opened");
expect(types).toContain("closed");
});
});
describe("getEventPayload", () => {
it("returns payload for push event", () => {
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// Verify common fields exist
expect(payload?.get("ref")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
expect(payload?.get("sender")).toBeDefined();
});
it("returns payload for issues event", () => {
const payload = getEventPayload("issues", "opened");
expect(payload).toBeDefined();
expect(payload?.get("action")).toBeDefined();
expect(payload?.get("issue")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
});
it("preserves descriptions for hover documentation", () => {
// This test ensures bodyParameters[].description is not stripped
// during JSON optimization. The description field is used for hover
// documentation in the workflow editor.
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// Get the description for a well-known field
// repository should have a description like "A repository on GitHub"
const repoDescription = payload?.getDescription("repository");
expect(repoDescription).toBeDefined();
expect(repoDescription?.length).toBeGreaterThan(0);
// sender should have a description
const senderDescription = payload?.getDescription("sender");
expect(senderDescription).toBeDefined();
expect(senderDescription?.length).toBeGreaterThan(0);
});
it("preserves childParamsGroups for nested property access", () => {
// This test ensures bodyParameters[].childParamsGroups is not stripped
// during JSON optimization. childParamsGroups defines nested properties
// used for autocompletion like github.event.repository.owner.login
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// repository has nested properties like owner, license, etc.
const repository = payload?.get("repository") as DescriptionDictionary | undefined;
expect(repository).toBeDefined();
// repository.owner should exist (nested via childParamsGroups)
const owner = repository?.get("owner") as DescriptionDictionary | undefined;
expect(owner).toBeDefined();
// repository.owner.login should exist (deeply nested)
const login = owner?.get("login");
expect(login).toBeDefined();
});
it("preserves name fields for property identification", () => {
// This test ensures bodyParameters[].name is not stripped
// during JSON optimization. The name field identifies each property.
const payload = getEventPayload("issues", "opened");
expect(payload).toBeDefined();
// Verify well-known property names exist
expect(payload?.get("action")).toBeDefined();
expect(payload?.get("issue")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
expect(payload?.get("sender")).toBeDefined();
// Verify nested property names work
const issue = payload?.get("issue") as DescriptionDictionary | undefined;
expect(issue?.get("title")).toBeDefined();
expect(issue?.get("number")).toBeDefined();
expect(issue?.get("user")).toBeDefined();
});
it("returns undefined for unknown event", () => {
const payload = getEventPayload("not_a_real_event", "default");
expect(payload).toBeUndefined();
});
});
});
@@ -1,10 +1,10 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import webhookObjects from "./objects.json";
import webhooks from "./webhooks.json";
import webhookObjects from "./objects.min.json";
import webhooks from "./webhooks.min.json";
import schedule from "./schedule.json";
import workflow_call from "./workflow_call.json";
import schedule from "./schedule.min.json";
import workflow_call from "./workflow_call.min.json";
const customEventPayloads: {[name: string]: unknown} = {
schedule,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -111,7 +111,7 @@ jobs:
on: push
jobs:
a:
uses: ./reusable-workflow-with-outputs.yaml
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
b:
needs: [a]
@@ -14,7 +14,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
us|ername:
`;
@@ -31,7 +31,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs-no-description.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs-no-description.yaml
with:
us|ername:
`;
@@ -48,7 +48,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-outputs.yaml
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
echo_outputs:
runs-on: ubuntu-latest
needs: build
+2 -5
View File
@@ -110,11 +110,8 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00\n\n" +
"Actions schedules run at most every 5 minutes. " +
"[Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
);
// Cron description is now shown via diagnostics, not hover
expect(result?.contents).toEqual("");
});
it("on a cron mapping key", async () => {
+2 -24
View File
@@ -2,11 +2,9 @@ import {data, DescriptionDictionary, Parser} from "@actions/expressions";
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
import {Lexer} from "@actions/expressions/lexer";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {isBasicExpression, isString} from "@actions/workflow-parser/templates/tokens/type-guards";
import {isBasicExpression} from "@actions/workflow-parser/templates/tokens/type-guards";
import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
@@ -23,7 +21,7 @@ import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-p
import {HoverVisitor} from "./expression-hover/visitor";
import {info} from "./log";
import {isPotentiallyExpression} from "./utils/expression-detection";
import {findToken, TokenResult} from "./utils/find-token";
import {findToken} from "./utils/find-token";
import {mapRange} from "./utils/range";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
@@ -89,17 +87,6 @@ export async function hover(document: TextDocument, position: Position, config?:
info(`Calculating hover for token with definition ${token.definition.key}`);
if (tokenResult.parent && isCronMappingValue(tokenResult)) {
const tokenValue = (token as StringToken).value;
const description = getCronDescription(tokenValue);
if (description) {
return {
contents: description,
range: mapRange(token.range)
} satisfies Hover;
}
}
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
description = appendContext(description, token.definitionInfo?.allowedContext);
@@ -156,15 +143,6 @@ async function getDescription(
return description || defaultDescription;
}
function isCronMappingValue(tokenResult: TokenResult): boolean {
return (
tokenResult.parent?.definition?.key === "cron-mapping" &&
!!tokenResult.token &&
isString(tokenResult.token) &&
tokenResult.token.value !== "cron"
);
}
function expressionHover(
exprPos: ExpressionPos,
context: DescriptionDictionary,
@@ -5,9 +5,9 @@ export const testFileProvider: FileProvider = {
// eslint-disable-next-line @typescript-eslint/require-await
getFileContent: async ref => {
switch (fileIdentifier(ref)) {
case "monalisa/octocat/workflow.yaml@main":
case "monalisa/octocat/.github/workflows/workflow.yaml@main":
return {
name: "monalisa/octocat/workflow.yaml",
name: "monalisa/octocat/.github/workflows/workflow.yaml",
content: `
on: workflow_call
jobs:
@@ -31,9 +31,9 @@ jobs:
`
};
case "./reusable-workflow.yaml":
case "./.github/workflows/reusable-workflow.yaml":
return {
name: "reusable-workflow.yaml",
name: ".github/workflows/reusable-workflow.yaml",
content: `
on: workflow_call
jobs:
@@ -44,9 +44,9 @@ jobs:
`
};
case "./reusable-workflow-with-inputs.yaml":
case "./.github/workflows/reusable-workflow-with-inputs.yaml":
return {
name: "reusable-workflow-with-inputs.yaml",
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
content: `
on:
workflow_call:
@@ -76,9 +76,9 @@ jobs:
`
};
case "./reusable-workflow-with-inputs-no-description.yaml":
case "./.github/workflows/reusable-workflow-with-inputs-no-description.yaml":
return {
name: "reusable-workflow-with-inputs.yaml",
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
content: `
on:
workflow_call:
@@ -95,9 +95,9 @@ jobs:
`
};
case "./reusable-workflow-with-outputs.yaml":
case "./.github/workflows/reusable-workflow-with-outputs.yaml":
return {
name: "reusable-workflow-with-outputs.yaml",
name: ".github/workflows/reusable-workflow-with-outputs.yaml",
content: `
on:
workflow_call:
@@ -635,7 +635,7 @@ jobs:
fail-fast: true
matrix:
node: [14, 16]
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
username: User-\${{ strategy.fail-fast }}
`;
@@ -654,7 +654,7 @@ jobs:
strategy:
matrix:
node: [14, 16]
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
username: \${{ matrix.node }}
`;
+91 -1
View File
@@ -181,7 +181,7 @@ jobs:
expect(result.length).toBe(1);
expect(result[0]).toEqual({
message: "Invalid cron string",
message: "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
range: {
end: {
character: 21,
@@ -195,6 +195,96 @@ jobs:
} as Diagnostic);
});
it("cron with interval less than 5 minutes shows warning", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on:
schedule:
- cron: '*/1 * * * *'
jobs:
build:
runs-on: ubuntu-latest`
),
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(1);
expect(result[0]).toEqual({
message:
'Actions schedules run at most every 5 minutes. "*/1 * * * *" (runs every minute) will not run as frequently as specified.',
severity: DiagnosticSeverity.Warning,
code: "on-schedule",
codeDescription: {
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
},
range: {
end: {
character: 25,
line: 2
},
start: {
character: 12,
line: 2
}
}
} as Diagnostic);
});
it("cron with interval of 5 minutes or more shows info", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on:
schedule:
- cron: '*/5 * * * *'
jobs:
build:
runs-on: ubuntu-latest`
),
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(1);
expect(result[0]).toEqual({
message: "Runs every 5 minutes",
severity: DiagnosticSeverity.Information,
code: "on-schedule",
codeDescription: {
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
},
range: {
end: {
character: 25,
line: 2
},
start: {
character: 12,
line: 2
}
}
} as Diagnostic);
});
it("cron with comma-separated minutes less than 5 apart shows warning", async () => {
const result = await validate(
createDocument(
"wf.yaml",
`on:
schedule:
- cron: '0,2 * * * *'
jobs:
build:
runs-on: ubuntu-latest`
),
{valueProviderConfig: defaultValueProviders}
);
expect(result.length).toBe(1);
expect(result[0]?.severity).toBe(DiagnosticSeverity.Warning);
expect(result[0]?.message).toContain("Actions schedules run at most every 5 minutes.");
});
it("invalid YAML", async () => {
// This YAML has some mismatched single-quotes, which causes the string to be terminated early
// within the fromJSON() expression.
+378
View File
@@ -2,6 +2,7 @@ import {Lexer, Parser, data} from "@actions/expressions";
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
@@ -27,6 +28,9 @@ import {validateAction} from "./validate-action";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
import {defaultValueProviders} from "./value-providers/default";
const CRON_SCHEDULE_DOCS_URL =
"https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule";
export type ValidationConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
@@ -143,11 +147,34 @@ async function additionalValidations(
}
}
// Validate step uses field format
if (isString(token) && token.range && validationDefinition?.key === "step-uses") {
validateStepUsesFormat(diagnostics, token);
}
// Validate action metadata (inputs, required fields) for regular steps
if (token.definition?.key === "regular-step" && token.range) {
const context = getProviderContext(documentUri, template, root, token.range);
await validateAction(diagnostics, token, context.step, config);
}
// Validate job-level reusable workflow uses field format
if (
isString(token) &&
token.range &&
key &&
isString(key) &&
key.value === "uses" &&
parent?.definition?.key === "workflow-job"
) {
validateWorkflowUsesFormat(diagnostics, token);
}
// Validate cron expressions - warn if interval is less than 5 minutes
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
validateCronExpression(diagnostics, token);
}
// Allowed values coming from the schema have already been validated. Only check if
// a value provider is defined for a token and if it is, validate the values match.
if (token.range && validationDefinition) {
@@ -198,6 +225,357 @@ function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: Value
}
}
/**
* Validates cron expressions and provides diagnostics for valid cron schedules.
* Shows a warning if the interval is less than 5 minutes (since GitHub Actions
* schedules run at most every 5 minutes), otherwise shows an info message.
*/
function validateCronExpression(diagnostics: Diagnostic[], token: StringToken): void {
const cronValue = token.value;
// Ensure we have a range for diagnostics
if (!token.range) {
return;
}
// Only check valid cron expressions - invalid ones are already caught by the parser
const description = getCronDescription(cronValue);
if (!description) {
return;
}
// Check if the cron specifies an interval less than 5 minutes
if (hasCronIntervalLessThan5Minutes(cronValue)) {
diagnostics.push({
message: `Actions schedules run at most every 5 minutes. "${cronValue}" (${description.toLowerCase()}) will not run as frequently as specified.`,
range: mapRange(token.range),
severity: DiagnosticSeverity.Warning,
code: "on-schedule",
codeDescription: {
href: CRON_SCHEDULE_DOCS_URL
}
});
} else {
// Show info message for valid cron expressions
diagnostics.push({
message: description,
range: mapRange(token.range),
severity: DiagnosticSeverity.Information,
code: "on-schedule",
codeDescription: {
href: CRON_SCHEDULE_DOCS_URL
}
});
}
}
/**
* Validates the format of a step's `uses` field.
*
* Valid formats:
* - docker://image:tag
* - ./local/path
* - .\local\path (Windows)
* - {owner}/{repo}@{ref}
* - {owner}/{repo}/{path}@{ref}
*/
function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
const uses = token.value;
// Empty uses value
if (!uses) {
diagnostics.push({
message: "`uses' value in action cannot be blank",
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
return;
}
// Docker image reference - always valid format
if (uses.startsWith("docker://")) {
return;
}
// Local action path - always valid format
if (uses.startsWith("./") || uses.startsWith(".\\")) {
return;
}
// Remote action: must be {owner}/{repo}[/path]@{ref}
const atSegments = uses.split("@");
// Must have exactly one @
if (atSegments.length !== 2) {
addStepUsesFormatError(diagnostics, token);
return;
}
const [repoPath, gitRef] = atSegments;
// Ref cannot be empty
if (!gitRef) {
addStepUsesFormatError(diagnostics, token);
return;
}
// Split by / or \ to get path segments
const pathSegments = repoPath.split(/[\\/]/);
// Must have at least owner and repo (both non-empty)
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
addStepUsesFormatError(diagnostics, token);
return;
}
// Check if this is a reusable workflow reference (should be at job level, not step)
// Path would be like: owner/repo/.github/workflows/file.yml
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
diagnostics.push({
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
return;
}
}
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
diagnostics.push({
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
}
/**
* Validates the format of a job's `uses` field (reusable workflow reference).
*
* Valid formats:
* - {owner}/{repo}/.github/workflows/{filename}.yml@{ref}
* - {owner}/{repo}/.github/workflows/{filename}.yaml@{ref}
* - {owner}/{repo}/.github/workflows-lab/{filename}.yml@{ref}
* - {owner}/{repo}/.github/workflows-lab/{filename}.yaml@{ref}
* - ./.github/workflows/{filename}.yml
* - ./.github/workflows/{filename}.yaml
* - ./.github/workflows-lab/{filename}.yml
* - ./.github/workflows-lab/{filename}.yaml
*/
function validateWorkflowUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
const uses = token.value;
// Local workflow reference
if (uses.startsWith("./.github/workflows/") || uses.startsWith("./.github/workflows-lab/")) {
// Cannot have @ version for local workflows
if (uses.includes("@")) {
addWorkflowUsesFormatError(diagnostics, token, "cannot specify version when calling local workflows");
return;
}
// Must have .yml or .yaml extension
if (!uses.endsWith(".yml") && !uses.endsWith(".yaml")) {
addWorkflowUsesFormatError(
diagnostics,
token,
"workflow file should have either a '.yml' or '.yaml' file extension"
);
return;
}
// Must be at top level of .github/workflows/ or .github/workflows-lab/ (no subdirectories)
const pathParts = uses.split("/");
if (pathParts.length !== 4) {
// Expected: ".", ".github", "workflows" or "workflows-lab", "filename.yml"
addWorkflowUsesFormatError(
diagnostics,
token,
"workflows must be defined at the top level of the .github/workflows/ directory"
);
return;
}
// Filename cannot be just the extension
const filename = pathParts[3];
if (filename === ".yml" || filename === ".yaml") {
addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name");
return;
}
return;
}
// Malformed local workflow reference (starts with ./ but not in .github/workflows)
if (uses.startsWith("./")) {
addWorkflowUsesFormatError(diagnostics, token, "local workflow references must be rooted in '.github/workflows'");
return;
}
// Remote workflow reference: must have @ for version
const atSegments = uses.split("@");
if (atSegments.length === 1) {
addWorkflowUsesFormatError(diagnostics, token, "no version specified");
return;
}
if (atSegments.length > 2) {
addWorkflowUsesFormatError(diagnostics, token, "too many '@' in workflow reference");
return;
}
const [pathPart, version] = atSegments;
// Version cannot be empty
if (!version) {
addWorkflowUsesFormatError(diagnostics, token, "no version specified");
return;
}
// Must contain .github/workflows or .github/workflows-lab path
const workflowsMatch = pathPart.match(/\.github\/workflows(-lab)?\//);
if (!workflowsMatch || workflowsMatch.index === undefined) {
addWorkflowUsesFormatError(diagnostics, token, "references to workflows must be rooted in '.github/workflows'");
return;
}
// Split to get owner/repo and path
const pathIdx = workflowsMatch.index;
const nwoPart = pathPart.substring(0, pathIdx);
const workflowPath = pathPart.substring(pathIdx);
// Validate NWO part: must be owner/repo/
const nwoSegments = nwoPart.split("/").filter(s => s.length > 0);
if (nwoSegments.length !== 2) {
addWorkflowUsesFormatError(
diagnostics,
token,
"references to workflows must be prefixed with format 'owner/repository/' or './' for local workflows"
);
return;
}
// Validate owner and repo names
const [owner, repo] = nwoSegments;
const nwoError = validateNWO(owner, repo);
if (nwoError) {
addWorkflowUsesFormatError(diagnostics, token, nwoError);
return;
}
// Validate ref/version format
const refError = validateRefName(version);
if (refError) {
addWorkflowUsesFormatError(diagnostics, token, refError);
return;
}
// Validate workflow path is at top level
const workflowPathParts = workflowPath.split("/");
if (workflowPathParts.length !== 3) {
// Expected: ".github", "workflows" or "workflows-lab", "filename.yml"
addWorkflowUsesFormatError(
diagnostics,
token,
"workflows must be defined at the top level of the .github/workflows/ directory"
);
return;
}
// Must have .yml or .yaml extension
const filename = workflowPathParts[2];
if (!filename.endsWith(".yml") && !filename.endsWith(".yaml")) {
addWorkflowUsesFormatError(
diagnostics,
token,
"workflow file should have either a '.yml' or '.yaml' file extension"
);
return;
}
// Filename cannot be just the extension
if (filename === ".yml" || filename === ".yaml") {
addWorkflowUsesFormatError(diagnostics, token, "invalid workflow file name");
return;
}
}
function addWorkflowUsesFormatError(diagnostics: Diagnostic[], token: StringToken, reason: string): void {
diagnostics.push({
message: `Invalid workflow reference '${token.value}': ${reason}`,
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-workflow-uses-format"
});
}
/**
* Validates the git ref/version format.
* Based on Launch's ValidateRefName function.
*/
function validateRefName(refname: string): string | undefined {
if (refname.length === 0) {
return "no version specified";
}
// Cannot be the single character '@'
if (refname === "@") {
return "version cannot be the single character '@'";
}
// Cannot have certain invalid characters or sequences
const invalidSequences = ["?", "*", "[", "]", "\\", "~", "^", ":", "@{", "..", "//"];
for (const seq of invalidSequences) {
if (refname.includes(seq)) {
return `invalid character '${seq}' in version`;
}
}
// Cannot begin or end with a slash '/' or a dot '.'
if (refname.startsWith("/") || refname.endsWith("/") || refname.startsWith(".") || refname.endsWith(".")) {
return "version cannot begin or end with a slash '/' or a dot '.'";
}
// No slash-separated component can begin with a dot '.' or end with the sequence '.lock'
const components = refname.split("/");
for (const component of components) {
if (component.startsWith(".") || component.endsWith(".lock")) {
return `invalid version: ${refname}`;
}
}
// No ASCII control characters or whitespace
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f\x7f]/.test(refname)) {
return "version cannot have ASCII control characters";
}
if (/\s/.test(refname)) {
return "version cannot have whitespace";
}
return undefined;
}
/**
* Validates owner and repository names.
* Based on Launch's ValidateNWO function.
*/
function validateNWO(owner: string, repo: string): string | undefined {
// Owner name: can have word chars, dots, and hyphens
// \w in JS regex is [a-zA-Z0-9_]
if (!/^[\w.-]+$/.test(owner)) {
return "owner name must be a valid repository owner name";
}
// Repository name: can have word chars, dots, and hyphens
if (!/^[\w.-]+$/.test(repo)) {
return "repository name is invalid";
}
return undefined;
}
function getProviderContext(
documentUri: URI,
template: WorkflowTemplate,
@@ -0,0 +1,894 @@
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {validate} from "./validate";
import {createDocument} from "./test-utils/document";
import {clearCache} from "./utils/workflow-cache";
beforeEach(() => {
clearCache();
});
describe("validate uses format", () => {
describe("valid formats", () => {
it("standard org/repo@ref", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("org/repo with path @ref", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/aws/ec2@main
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("org/repo with deep path @ref", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/aws/nested/deep/path@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("docker image", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: docker://alpine:3.8
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("docker image with registry", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: docker://gcr.io/my-project/my-image:latest
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("local path with ./", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: ./my-action
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("local path with ./ and subdirectories", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/my-action
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("local path with .\\ (Windows)", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: .\\my-action
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("SHA ref", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("branch ref", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: owner/repo@feature/my-branch
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
});
describe("invalid formats", () => {
it("missing @ref", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 28}
},
code: "invalid-uses-format"
}
]);
});
it("empty ref", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout@'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 29}
},
code: "invalid-uses-format"
}
]);
});
it("missing org/owner", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: checkout@v4
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'checkout@v4'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 23}
},
code: "invalid-uses-format"
}
]);
});
it("empty owner", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: /repo@v4
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual '/repo@v4'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 20}
},
code: "invalid-uses-format"
}
]);
});
it("empty repo", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: owner/@v4
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'owner/@v4'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 21}
},
code: "invalid-uses-format"
}
]);
});
it("multiple @ symbols", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4@extra
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'actions/checkout@v4@extra'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 37}
},
code: "invalid-uses-format"
}
]);
});
it("just a name with no slash", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: checkout
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Expected format {owner}/{repo}[/path]@{ref}. Actual 'checkout'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 20}
},
code: "invalid-uses-format"
}
]);
});
it("empty uses value", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: ""
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual({
message: "`uses' value in action cannot be blank",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 14}
},
code: "invalid-uses-format"
});
});
it("reusable workflow in step", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: owner/repo/.github/workflows/test.yml@main
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 5, character: 12},
end: {line: 5, character: 54}
},
code: "invalid-uses-format"
}
]);
});
});
});
describe("workflow uses format validation", () => {
beforeEach(() => {
clearCache();
});
describe("valid formats", () => {
it("local workflow path", async () => {
const input = `on: push
jobs:
test:
uses: ./.github/workflows/test.yml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("local workflow path with yaml extension", async () => {
const input = `on: push
jobs:
test:
uses: ./.github/workflows/test.yaml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("remote workflow with version", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("remote workflow with sha ref", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@abc123
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("remote workflow with branch ref", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@main
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("remote workflow with yaml extension", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yaml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("local workflows-lab path", async () => {
const input = `on: push
jobs:
test:
uses: ./.github/workflows-lab/test.yml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("local workflows-lab path with yaml extension", async () => {
const input = `on: push
jobs:
test:
uses: ./.github/workflows-lab/test.yaml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
it("remote workflows-lab with version", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows-lab/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([]);
});
});
describe("invalid formats", () => {
it("remote workflow missing version", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference 'owner/repo/.github/workflows/test.yml': no version specified",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 47}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("local workflow with version", async () => {
const input = `on: push
jobs:
test:
uses: ./.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference './.github/workflows/test.yml@v1': cannot specify version when calling local workflows",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 41}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("malformed local path not in .github/workflows", async () => {
const input = `on: push
jobs:
test:
uses: ./foo/bar.yml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference './foo/bar.yml': local workflow references must be rooted in '.github/workflows'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 23}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("missing .github/workflows path", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/test.yml@v1': references to workflows must be rooted in '.github/workflows'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 32}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("invalid file extension", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.txt@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.txt@v1': workflow file should have either a '.yml' or '.yaml' file extension",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 50}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("no extension", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test@v1': workflow file should have either a '.yml' or '.yaml' file extension",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 46}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("just a ref", async () => {
const input = `on: push
jobs:
test:
uses: test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'test.yml@v1': references to workflows must be rooted in '.github/workflows'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 21}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("local without .github/workflows", async () => {
const input = `on: push
jobs:
test:
uses: ./workflows/test.yml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference './workflows/test.yml': local workflow references must be rooted in '.github/workflows'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 30}
},
code: "invalid-workflow-uses-format"
}
]);
});
describe("invalid ref/version format", () => {
it("empty version after @", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference 'owner/repo/.github/workflows/test.yml@': no version specified",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 48}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version with invalid character ?", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1?
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1?': invalid character '?' in version",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 51}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version with double dots", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1..v2
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1..v2': invalid character '..' in version",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 54}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version ending with dot", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1.
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1.': version cannot begin or end with a slash '/' or a dot '.'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 51}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version starting with slash", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@/v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@/v1': version cannot begin or end with a slash '/' or a dot '.'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 51}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version ending with .lock", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@refs/heads/main.lock
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@refs/heads/main.lock': invalid version: refs/heads/main.lock",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 68}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version with whitespace", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1 && rm -rf
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1 && rm -rf': version cannot have whitespace",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 60}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version with backslash", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1\\1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1\\1': invalid character '\\' in version",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 52}
},
code: "invalid-workflow-uses-format"
}
]);
});
});
describe("invalid owner/repo names", () => {
it("owner with invalid characters", async () => {
const input = `on: push
jobs:
test:
uses: owner*/repo/.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner*/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 51}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("repo with invalid characters", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo!name/.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo!name/.github/workflows/test.yml@v1': repository name is invalid",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 55}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("owner with spaces", async () => {
const input = `on: push
jobs:
test:
uses: owner name/repo/.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner name/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 55}
},
code: "invalid-workflow-uses-format"
}
]);
});
});
describe("invalid workflow filename", () => {
it("filename is just .yml", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yml@v1': invalid workflow file name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 46}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("filename is just .yaml", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/.yaml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yaml@v1': invalid workflow file name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 47}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("local workflow filename is just .yml", async () => {
const input = `on: push
jobs:
test:
uses: ./.github/workflows/.yml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference './.github/workflows/.yml': invalid workflow file name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 34}
},
code: "invalid-workflow-uses-format"
}
]);
});
});
});
});
@@ -43,7 +43,7 @@ on: push
jobs:
build:
uses: monalisa/octocat/workflow.yaml@not-a-branch
uses: monalisa/octocat/.github/workflows/workflow.yaml@not-a-branch
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
@@ -58,7 +58,7 @@ jobs:
line: 5
},
end: {
character: 53,
character: 71,
line: 5
}
}
@@ -72,7 +72,7 @@ on: push
jobs:
build:
uses: monalisa/octocat/workflow.yaml@main
uses: monalisa/octocat/.github/workflows/workflow.yaml@main
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
@@ -87,7 +87,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow.yaml
uses: ./.github/workflows/reusable-workflow.yaml
`;
const result = await validate(createDocument("wf.yaml", input), {
fileProvider: testFileProvider
@@ -102,7 +102,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
secrets:
envPAT: pat
`;
@@ -119,7 +119,7 @@ jobs:
line: 5
},
end: {
character: 46,
character: 64,
line: 5
}
}
@@ -133,7 +133,7 @@ on: push
jobs:
build:
uses: ./reusable-workflow-with-inputs.yaml
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
with:
username: monalisa
secrets:
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.20"
"version": "0.3.23"
}
+84 -167
View File
@@ -135,7 +135,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.20",
"version": "0.3.23",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -340,9 +340,9 @@
}
},
"expressions/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"dependencies": {
"argparse": "^2.0.1"
@@ -395,11 +395,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.20",
"version": "0.3.23",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@actions/languageservice": "^0.3.23",
"@actions/workflow-parser": "^0.3.23",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -921,11 +921,11 @@
},
"languageservice": {
"name": "@actions/languageservice",
"version": "0.3.20",
"version": "0.3.23",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@actions/expressions": "^0.3.23",
"@actions/workflow-parser": "^0.3.23",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -1136,9 +1136,9 @@
}
},
"languageservice/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"dependencies": {
"argparse": "^2.0.1"
@@ -1218,89 +1218,19 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.22.13",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true,
"dependencies": {
"@babel/highlight": "^7.22.13",
"chalk": "^2.4.2"
"@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/code-frame/node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/code-frame/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/code-frame/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/@babel/code-frame/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"dev": true
},
"node_modules/@babel/code-frame/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@babel/code-frame/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/code-frame/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@babel/compat-data": {
"version": "7.20.1",
"dev": true,
@@ -1483,18 +1413,18 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"engines": {
"node": ">=6.9.0"
@@ -1509,13 +1439,13 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.20.1",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.18.10",
"@babel/traverse": "^7.20.1",
"@babel/types": "^7.20.0"
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.4"
},
"engines": {
"node": ">=6.9.0"
@@ -1600,10 +1530,13 @@
}
},
"node_modules/@babel/parser": {
"version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
"integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"dev": true,
"dependencies": {
"@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -1786,14 +1719,14 @@
}
},
"node_modules/@babel/template": {
"version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
"integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.22.13",
"@babel/parser": "^7.22.15",
"@babel/types": "^7.22.15"
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.2",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1821,14 +1754,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.23.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
"integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"dev": true,
"dependencies": {
"@babel/helper-string-parser": "^7.22.5",
"@babel/helper-validator-identifier": "^7.22.20",
"to-fast-properties": "^2.0.0"
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -4231,12 +4163,10 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
"version": "7.3.8",
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
@@ -4361,12 +4291,10 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.3.8",
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
@@ -4402,12 +4330,10 @@
}
},
"node_modules/@typescript-eslint/utils/node_modules/semver": {
"version": "7.3.8",
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
@@ -4877,9 +4803,10 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -6265,12 +6192,10 @@
}
},
"node_modules/eslint/node_modules/semver": {
"version": "7.3.8",
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
@@ -8392,12 +8317,10 @@
}
},
"node_modules/jest-snapshot/node_modules/semver": {
"version": "7.3.8",
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
@@ -8510,9 +8433,10 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.14.1",
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -9514,11 +9438,12 @@
}
},
"node_modules/micromatch": {
"version": "4.0.5",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.2",
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
@@ -10760,9 +10685,10 @@
}
},
"node_modules/picocolors": {
"version": "1.0.0",
"dev": true,
"license": "ISC"
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -11524,9 +11450,10 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "6.3.0",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
@@ -12147,14 +12074,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/to-fast-properties": {
"version": "2.0.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -12235,12 +12154,10 @@
}
},
"node_modules/ts-jest/node_modules/semver": {
"version": "7.3.8",
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
@@ -12917,10 +12834,10 @@
},
"workflow-parser": {
"name": "@actions/workflow-parser",
"version": "0.3.20",
"version": "0.3.23",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.20",
"@actions/expressions": "^0.3.23",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+1
View File
@@ -1,6 +1,7 @@
{
"name": "actions-languageservices",
"private": true,
"type": "module",
"workspaces": [
"./expressions",
"./workflow-parser",
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
/**
* Minifies JSON files by removing whitespace.
*
* Usage: node script/minify-json.js <file1.json> <file2.json> ...
*
* For each input file, creates a corresponding .min.json file.
* Example: src/data.json -> src/data.min.json
*/
import {promises as fs} from "fs";
import path from "path";
const files = process.argv.slice(2);
if (files.length === 0) {
console.error("Usage: node script/minify-json.js <file1.json> <file2.json> ...");
process.exit(1);
}
for (const file of files) {
try {
const content = await fs.readFile(file, "utf8");
const data = JSON.parse(content);
const minified = JSON.stringify(data);
// Replace .json with .min.json
const ext = path.extname(file);
const outputFile = file.slice(0, -ext.length) + ".min" + ext;
await fs.writeFile(outputFile, minified);
const originalSize = Buffer.byteLength(content, "utf8");
const minifiedSize = Buffer.byteLength(minified, "utf8");
const savings = ((1 - minifiedSize / originalSize) * 100).toFixed(1);
console.log(`${file} -> ${outputFile} (${savings}% smaller)`);
} catch (err) {
console.error(`Error processing ${file}:`, err);
process.exit(1);
}
}
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.20",
"version": "0.3.23",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -38,14 +38,17 @@
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"minify-json": "node ../script/minify-json.js src/workflow-v1.0.json",
"prebuild": "npm run minify-json",
"prepublishOnly": "npm run build && npm run test",
"pretest": "npm run minify-json",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-xlang": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathPattern xlang",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.20",
"@actions/expressions": "^0.3.23",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
@@ -1,4 +1,4 @@
import {isValidCron, getCronDescription} from "./cron";
import {isValidCron, getCronDescription, hasCronIntervalLessThan5Minutes} from "./cron";
describe("cron", () => {
describe("valid cron", () => {
@@ -66,14 +66,54 @@ describe("cron", () => {
describe("getCronDescription", () => {
it(`Produces a sentence for valid cron`, () => {
expect(getCronDescription("0 * * * *")).toEqual(
"Runs every hour\n\n" +
"Actions schedules run at most every 5 minutes. [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
);
expect(getCronDescription("0 * * * *")).toEqual("Runs every hour");
});
it(`Returns nothing for invalid cron`, () => {
expect(getCronDescription("* * * * * *")).toBeUndefined();
});
});
describe("hasCronIntervalLessThan5Minutes", () => {
it("returns true for step expressions with interval < 5 min", () => {
expect(hasCronIntervalLessThan5Minutes("*/1 * * * *")).toBe(true);
expect(hasCronIntervalLessThan5Minutes("*/4 * * * *")).toBe(true);
});
it("returns false for step expressions with interval >= 5 min", () => {
expect(hasCronIntervalLessThan5Minutes("*/5 * * * *")).toBe(false);
expect(hasCronIntervalLessThan5Minutes("*/15 * * * *")).toBe(false);
});
it("returns true for comma-separated values with gap < 5 min", () => {
expect(hasCronIntervalLessThan5Minutes("0,2,4 * * * *")).toBe(true);
expect(hasCronIntervalLessThan5Minutes("0,10,12 * * * *")).toBe(true);
});
it("returns false for comma-separated values with gap >= 5 min", () => {
expect(hasCronIntervalLessThan5Minutes("0,10,20 * * * *")).toBe(false);
expect(hasCronIntervalLessThan5Minutes("0,30 * * * *")).toBe(false);
});
it("returns true for comma-separated values with wrap-around gap < 5 min", () => {
expect(hasCronIntervalLessThan5Minutes("0,58 * * * *")).toBe(true);
expect(hasCronIntervalLessThan5Minutes("2,59 * * * *")).toBe(true);
});
it("returns true for * (every minute)", () => {
expect(hasCronIntervalLessThan5Minutes("* * * * *")).toBe(true);
});
it("returns true for range expressions (runs every minute in range)", () => {
expect(hasCronIntervalLessThan5Minutes("0-4 * * * *")).toBe(true);
});
it("returns false for single value (hourly)", () => {
expect(hasCronIntervalLessThan5Minutes("0 * * * *")).toBe(false);
});
it("returns false for invalid cron", () => {
expect(hasCronIntervalLessThan5Minutes("invalid")).toBe(false);
});
});
});
+73 -5
View File
@@ -8,6 +8,78 @@ type Range = {
names?: Record<string, number>;
};
/**
* Checks if a cron expression specifies an interval shorter than 5 minutes.
* GitHub Actions schedules run at most every 5 minutes, so intervals < 5 min won't work as expected.
*/
export function hasCronIntervalLessThan5Minutes(cron: string): boolean {
if (!isValidCron(cron)) {
return false;
}
const parts = cron.split(/ +/);
const minutePart = parts[0];
// Parse the minute field to determine the effective interval
return getMinuteInterval(minutePart) < 5;
}
/**
* Gets the minimum interval in minutes between cron executions based on the minute field.
* Returns 60 if there's only one execution per hour, otherwise returns the minimum gap.
*/
function getMinuteInterval(minutePart: string): number {
// Handle step expressions like */1, */3, 0-59/2
if (minutePart.includes("/")) {
const [, step] = minutePart.split("/");
const stepNum = parseInt(step, 10);
if (!isNaN(stepNum) && stepNum > 0) {
return stepNum;
}
}
// Handle comma-separated values like 0,2,4 or 0,1,5,10
if (minutePart.includes(",")) {
const values = minutePart
.split(",")
.map(v => parseInt(v, 10))
.filter(n => !isNaN(n))
.sort((a, b) => a - b);
if (values.length >= 2) {
let minGap = 60;
for (let i = 1; i < values.length; i++) {
const gap = values[i] - values[i - 1];
if (gap < minGap) {
minGap = gap;
}
}
// Check wrap-around gap from last minute to first minute of next hour
const wrapGap = values[0] + 60 - values[values.length - 1];
if (wrapGap < minGap) {
minGap = wrapGap;
}
return minGap;
}
}
// Handle range expressions like 0-4 (runs every minute from 0-4)
if (minutePart.includes("-") && !minutePart.includes("/")) {
const [start, end] = minutePart.split("-").map(v => parseInt(v, 10));
if (!isNaN(start) && !isNaN(end) && end > start) {
// A range without step means every minute in that range
return 1;
}
}
// * means every minute
if (minutePart === "*") {
return 1;
}
// Single value or unrecognized pattern - assume hourly (60 min interval)
return 60;
}
export function isValidCron(cron: string): boolean {
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
@@ -46,11 +118,7 @@ export function getCronDescription(cronspec: string): string | undefined {
}
// Make first character lowercase
let result = "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
result +=
"\n\nActions schedules run at most every 5 minutes." +
" [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)";
return result;
return "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
}
function validateCronPart(value: string, range: Range, allowSeparators = true): boolean {
@@ -158,7 +158,7 @@ function convertSchedule(context: TemplateContext, token: SequenceToken): Schedu
const cron = schedule.value.assertString(`schedule cron`);
// Validate the cron string
if (!isValidCron(cron.value)) {
context.error(cron, "Invalid cron string");
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
}
result.push({cron: cron.value});
} else {
@@ -1,6 +1,6 @@
import {JSONObjectReader} from "../templates/json-object-reader";
import {TemplateSchema} from "../templates/schema";
import WorkflowSchema from "../workflow-v1.0.json";
import WorkflowSchema from "../workflow-v1.0.min.json";
let schema: TemplateSchema;