Compare commits

..
44 changed files with 2519 additions and 1590 deletions
+666
View File
@@ -0,0 +1,666 @@
# Bundle Size Investigation
## Current State
**Package sizes on disk (in github-ui node_modules):**
- `@actions/languageservice`: **7.9M**
- `@actions/workflow-parser`: **1.5M**
- `@actions/expressions`: **560K**
- **Total: ~10M**
**Largest files:**
| File | Size | % of total |
|------|------|------------|
| `languageservice/dist/context-providers/events/webhooks.json` | 6.2M | 62% |
| `languageservice/dist/context-providers/events/objects.json` | 948K | 9.5% |
| `workflow-parser/dist/workflow-v1.0.json` | 112K | 1% |
| `languageservice/dist/context-providers/descriptions.json` | 20K | <1% |
## JSON File Analysis
### What `webhooks.json` is used for
Provides autocomplete and validation for `github.*` context expressions. When you type `${{ github.event.` the language service uses this data to:
- Suggest available properties based on event type (push, pull_request, etc.)
- Provide descriptions for hover tooltips
- Validate property access is valid for the event type
### Field usage analysis
| Field | Location | Size | Used for Autocomplete | Used for Validation | Used for Hover |
|-------|----------|------|----------------------|---------------------|----------------|
| `bodyParameters[].description` | Inside each param | Part of bodyParams | ✅ Documentation popup | ✅ Property existence | ✅ Descriptions |
| `bodyParameters[].name/type/etc` | Inside each param | 1.55 MB total | ✅ Property names | ✅ Property existence | ✅ Structure |
| `description` | Top-level on event | 17 KB | ❌ Defined but unused | ❌ | ❌ |
| `summary` | Top-level on event | 155 KB | ❌ | ❌ | ❌ |
| `availability` | Top-level on event | 7 KB | ❌ | ❌ | ❌ |
| `category` | Top-level on event | 3 KB | ❌ | ❌ | ❌ |
| `action` | Top-level on event | 2 KB | ❌ | ❌ | ❌ |
**Key insight:** `bodyParameters` (including nested `description` fields) is used for ALL features. The **top-level** fields (`summary`, `description`, `availability`, `category`, `action`) are defined in the TypeScript types but never actually accessed in code - they can be stripped.
### Why top-level `description`/`summary` shouldn't be used for workflow events
**Question:** Could we use the webhooks.json top-level `description` or `summary` fields to enhance autocomplete/hover for the `on:` field?
**Answer:** No - they serve different purposes and the existing solution is better.
**Comparison:**
| Source | Example for `push` | Purpose |
|--------|-------------------|---------|
| `workflow-v1.0.json` (current) | "Runs your workflow when you push a commit or tag." | **User-facing** - explains what triggers the workflow |
| `webhooks.json` description | "A push was made to a repository branch..." | **API-facing** - describes the GitHub API event |
| `webhooks.json` summary | "This event occurs when a commit or tag is pushed. To subscribe to this event, a GitHub App must have at least read-level access..." | **App developer-facing** - API permissions info |
**The current solution is correct:**
- `workflow-v1.0.json` contains workflow-specific event descriptions written for GitHub Actions users
- These are shown in autocomplete/hover when completing `on: push`, `on: pull_request`, etc.
- Located in `languageservice/src/value-providers/definition.ts` line 46: `description: def.description`
**The webhooks.json descriptions would be wrong:**
- Written for GitHub App developers, not GitHub Actions users
- Include irrelevant details (API permissions, subscription info)
- Don't explain what happens in the context of a workflow
**Conclusion:** Keep the top-level fields stripped - they're not needed and would be confusing if used.
### Minification analysis
| File | Pretty Size | Minified Size | Savings |
|------|-------------|---------------|---------|
| `webhooks.json` | 4.1 MB | 1.6 MB | **2.5 MB (60.5%)** |
| `objects.json` | 666 KB | 325 KB | **341 KB (51.3%)** |
| `workflow-v1.0.json` | 91 KB | 70 KB | **22 KB (23.5%)** |
**The files are NOT minified!** Just minifying saves 60%.
### Compression analysis (gzip)
Production servers typically gzip assets. Here's what matters for network transfer:
| File | Original | Minified | Gzipped | Min+Gzip |
|------|----------|----------|---------|----------|
| `webhooks.json` | 4.0 MB | 1.6 MB | 198 KB | **90 KB** |
| `objects.json` | 651 KB | 317 KB | 38 KB | **23 KB** |
| `workflow-v1.0.json` | 91 KB | 70 KB | 13 KB | **13 KB** |
**What matters for different concerns:**
| Concern | What matters |
|---------|--------------|
| **Network transfer** | Compressed size (gzip/brotli) - already small (~126 KB total) |
| **npm package size** | Uncompressed size on disk - affects install times |
| **Memory usage** | Parsed JSON object size in memory |
| **Parse time** | Uncompressed size (must decompress before parsing) |
**Key insight:** Network transfer is NOT the main concern (~126 KB gzipped). Minifying still matters for:
- Smaller npm package size (better install times)
- Less to decompress on client
- Faster JSON parsing (less text to parse)
## How the files are generated
The JSON files are **auto-generated** from GitHub's official REST API description:
```
npm run update-webhooks
```
**Source:** `github:github/rest-api-description` (GitHub's OpenAPI spec)
**Generation script:** `languageservice/script/webhooks/index.ts`
- Reads webhook definitions from the dereferenced OpenAPI schema
- Extracts body parameters, descriptions, summaries
- Runs deduplication to create `objects.json` (shared parameters stored once, referenced by index)
- Outputs pretty-printed JSON (not minified)
**Current deduplication strategy (`deduplicate.ts`):**
- Finds body parameters that appear in multiple webhooks
- Stores them once in `objects.json` array
- Replaces duplicates with numeric index references in `webhooks.json`
**Optimization opportunities in generation:**
1. Add minification step (remove whitespace) - easy, ~60% savings
2. Strip unused fields (`summary`, `availability`, `category`, `action`) - ~10% additional savings
3. Consider more aggressive deduplication (e.g., dedupe descriptions, nested objects)
### `workflow-v1.0.json` (workflow schema)
**Hand-authored** - not generated. Located in `workflow-parser/src/`.
Optimization: Minify at build time (112K pretty → smaller minified).
### Other Small JSON Files
| File | Purpose | Pretty | Minified | Further Optimized |
|------|---------|--------|----------|-------------------|
| `descriptions.json` | Hover descriptions for contexts/functions | 18 KB | 17 KB | N/A (all used) |
| `schedule.json` | Sample `github.event` for schedule trigger | 5.7 KB | 5.1 KB | **1.8 KB** (strip values) |
| `workflow_call.json` | Sample `github.event` for reusable workflows | 7.3 KB | 6.5 KB | **2.3 KB** (strip values) |
**Why `schedule.json` / `workflow_call.json` exist:**
These events are NOT webhooks - they're internal GitHub Actions triggers that don't appear in the REST API webhook definitions. The files provide sample `github.event` payloads so the language service knows what properties to autocomplete:
```
User types: ${{ github.event.repository.owner.login }}
Language service walks schedule.json to find valid property names
```
The code (`eventPayloads.ts` lines 109-116) uses `mergeObject()` to recursively extract property **names** - the actual values are never used.
**Key insight for `schedule.json` / `workflow_call.json`:** These files provide sample event payloads. The code only uses property **names** (for autocomplete like `github.event.repository.owner.login`), not values. The actual values (URLs, IDs, emails) can be replaced with `null`:
```javascript
// Original (5.1 KB)
{"repository":{"id":186853002,"name":"Hello-World","owner":{"login":"Codertocat",...},...},...}
// Stripped (1.8 KB) - same autocomplete functionality
{"repository":{"id":null,"name":null,"owner":{"login":null,...},...},...}
```
**Savings:** ~65% smaller for these files.
## JSON File Maintenance & Documentation
### TODO: Document maintenance procedures
| File | Source | How to Update | Documented? |
|------|--------|---------------|-------------|
| `webhooks.json` + `objects.json` | `npm run update-webhooks` from `rest-api-description` | Run script | ⚠️ Partial (in script) |
| `workflow-v1.0.json` | Hand-authored | Manual edits | ❌ No |
| `descriptions.json` | Hand-authored | Manual edits | ❌ No |
| `schedule.json` | Hand-authored sample payload | Manual edits | ❌ No - unclear origin |
| `workflow_call.json` | Hand-authored sample payload | Manual edits | ❌ No - unclear origin |
### Historical context (from git history):
- **`schedule.json`** - Added in commit `b68ac91` (Dec 2022) by Beth Brennan in "Use payload schema for events"
- Uses "Codertocat/Hello-World" sample data (appears to be from GitHub's webhook documentation examples)
- No documentation on where this came from or how to update it
- **Question:** Is this based on a real scheduled workflow run? How do we know it includes all possible properties?
- **`workflow_call.json`** - Same commit, similar questions
- **Many other event JSON files** were added in that same commit, but were later replaced by the generated `webhooks.json` system. Only `schedule.json` and `workflow_call.json` remain as manual files because they're not real webhooks.
### Questions to answer:
1. **`schedule.json`** - Where did this sample payload come from? Is it based on a real event? How do we know it's complete/accurate? Does it need updating when GitHub adds new repository properties?
2. **`workflow_call.json`** - Same questions. Was this captured from an actual workflow run?
3. **`descriptions.json`** - Are these descriptions synced from docs.github.com or manually maintained? How do we keep them up to date?
4. **`workflow-v1.0.json`** - What's the process for adding new workflow syntax (new keys, new event types)?
### Recommended actions:
1. **Add README files** - Each JSON file should have documentation explaining what it's for, how to update it, and who maintains it
2. **Automate where possible** - Could `schedule.json` be generated from a real scheduled workflow run's `github.event`? Could we capture a sample automatically?
3. **Add tests** - Validate that sample payloads match expected structure
### ⚠️ BUG: `workflow_call.json` may be incorrect/useless
**Finding:** For `on: workflow_call` (reusable workflows), the `github.event` context is **inherited from the calling workflow**. If the caller was triggered by `push`, then `github.event` contains push data. If by `pull_request`, it contains PR data.
**Current behavior in `github.ts`:**
```typescript
// Line 87-89 - For VALIDATION mode, returns Null (any value allowed)
if (eventsConfig.workflow_call && mode == Mode.Validation) {
return new data.Null();
}
// But for COMPLETION/HOVER mode, falls through and uses workflow_call.json!
```
**Problem:** `workflow_call.json` contains generic repo/sender/org data, but this is WRONG for autocomplete. When you type `${{ github.event.` in a reusable workflow, showing `repository`, `sender`, etc. is misleading because:
- The actual properties depend on how the workflow was called
- Could be push properties, PR properties, or anything else
**Recommendation:**
- Either return `Null` for completion/hover too (show nothing, since we can't know)
- Or remove `workflow_call.json` entirely since it's actively misleading
- This would save 7KB and fix a bug!
## npm Package Sizes
The actual npm package sizes (gzipped tarballs) are much smaller than disk size:
| Package | Disk Size | Package Size (gzipped) | Unpacked |
|---------|-----------|------------------------|----------|
| `@actions/languageservice` | 7.9M | **368 KB** | 7.7 MB |
| `@actions/workflow-parser` | 1.5M | **98 KB** | 548 KB |
| `@actions/expressions` | 560K | **34 KB** | 153 KB |
| **Total** | ~10M | **~500 KB** | ~8.4 MB |
**Key insight:** npm install downloads ~500KB gzipped. The disk/memory impact is ~8.4 MB unpacked.
## Dependencies Analysis
**Direct dependencies:**
| Package | Disk Size | Used By | Notes |
|---------|-----------|---------|-------|
| `yaml` | 1.4 MB | workflow-parser, languageservice | Full YAML parser, well-structured |
| `cronstrue` | 1.4 MB | workflow-parser | Cron → human text. Main: 44KB (no i18n) |
| `vscode-languageserver-types` | 396 KB | languageservice | Type definitions for LSP |
| `vscode-languageserver-textdocument` | 72 KB | languageservice | Text document handling |
| `vscode-uri` | 256 KB | languageservice | URI parsing |
**Observations:**
- `cronstrue` has a 44KB main entry (without i18n) vs 238KB with i18n. Bundlers should use the smaller one.
- `yaml` is necessary - no lighter alternative for full YAML parsing
- `vscode-*` packages are minimal and necessary for LSP compatibility
## Areas to Investigate
1.**Total bundle size** - Analyzed above
2.**Specific heavy dependencies** - `cronstrue` and `yaml` analyzed
3. **Tree-shaking** - Whether unused code is being properly eliminated
4.**Load time impact** - Lazy-loaded in github-ui via dynamic import()
5.**JSON files for event validation** - Main culprit (6.2MB webhooks.json)
6.**Minifying the workflow schema JSON file** - 112K → can be minified
## Potential Optimizations
### High Impact
1. **Drop 31 unused webhook events** - Events like `installation`, `marketplace_purchase`, `sponsorship`, `star`, `team`, etc. are in `webhooks.json` but cannot be used as workflow triggers. Confirmed against [GitHub's official docs](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows).
| Metric | Before | After | Savings |
|--------|--------|-------|---------|
| Events | 63 | 32 | 31 dropped |
| Size | 1.76 MB | 1.42 MB | **19%** |
**Events to drop:**
```
code_scanning_alert, commit_comment, dependabot_alert, deploy_key,
github_app_authorization, installation, installation_repositories,
installation_target, marketplace_purchase, member, membership, meta,
org_block, organization, package, ping, projects_v2, projects_v2_item,
pull_request_review_thread, repository, repository_import,
repository_vulnerability_alert, secret_scanning_alert,
secret_scanning_alert_location, security_advisory, security_and_analysis,
sponsorship, star, team, team_add, workflow_job
```
2. **Strip unused fields** - Remove `summary`, `availability`, `category`, `action` fields that are never used by the language service. Only `bodyParameters` and `descriptionHtml` are needed.
3. **Minify JSON files** - Currently pretty-printed with whitespace. Minifying saves ~60%.
4. **Combined impact estimate:**
| Optimization | webhooks.json | objects.json |
|--------------|---------------|--------------|
| Original | 6.2 MB | 948 KB |
| Drop unused events | 5.0 MB (-19%) | 770 KB (-19%) |
| Strip unused fields | 3.0 MB (-40%) | 460 KB (-40%) |
| Minify | 1.2 MB (-60%) | 225 KB (-52%) |
| **Gzipped (network)** | **~60 KB** | **~20 KB** |
5. **Add `"sideEffects"` to all package.json files** - Enable tree-shaking across all packages:
- `expressions/package.json`: `"sideEffects": false`
- `workflow-parser/package.json`: `"sideEffects": false`
- `languageservice/package.json`: `"sideEffects": ["./dist/context-providers/events/eventPayloads.js"]`
### Medium Impact
6. **Minify `workflow-v1.0.json` schema (112K)** - Strip whitespace. Note: This file is hand-authored, not generated from webhook data.
7. **Minify and strip small JSON files** - `schedule.json`, `descriptions.json`:
- Minify all (remove whitespace)
- Strip values from `schedule.json` (only property names are used)
8. **Investigate `workflow_call.json` usage** - See bug section above. This file may be incorrect/useless:
- For `on: workflow_call`, `github.event` is inherited from the calling workflow
- Current code returns `Null` for validation (correct) but uses `workflow_call.json` for completion (incorrect?)
- Options: Remove file entirely, or fix code to return `Null` for all modes
- Saves 7KB + potentially fixes misleading autocomplete
9. **Lazy-load event validation data** - Refactor `eventPayloads.ts` to load JSON on first use instead of at import time.
### Low Impact / Further Investigation
10. **Tree-shake unused exports** - Ensure webpack is eliminating dead code.
11. **Evaluate `cronstrue` size** - Check if it's worth keeping or replacing with lighter alternative.
11. **Bundle analysis** - Run webpack-bundle-analyzer to see actual bundled sizes after minification/compression.
## Implementation Plan
### Phase 1: Update generation script (`languageservice/script/webhooks/index.ts`)
1. Add list of valid workflow trigger events (whitelist)
2. Filter out events not in whitelist during generation
3. Strip unused fields (`summary`, `availability`, `category`, `action`)
4. Output minified JSON (`JSON.stringify(data)` instead of `JSON.stringify(data, null, 2)`)
### Phase 1b: Minify/optimize small hand-authored JSON files
1. Minify `descriptions.json` (18 KB → 17 KB)
2. Strip values & minify `schedule.json` (5.7 KB → 1.8 KB)
3. Strip values & minify `workflow_call.json` (7.3 KB → 2.3 KB)
4. Minify `workflow-v1.0.json` (112 KB → ~90 KB)
### Phase 2: Add sideEffects to all package.json files
1. Add `"sideEffects": false` to `expressions/package.json`
2. Add `"sideEffects": false` to `workflow-parser/package.json`
3. Add `"sideEffects": ["./dist/context-providers/events/eventPayloads.js"]` to `languageservice/package.json`
### Phase 3: (Optional) Refactor for lazy loading
1. Move JSON imports inside functions
2. Remove top-level hydration code, make it lazy
### Phase 4: Automated JSON updates via GitHub Actions
Create workflows to automatically keep JSON files up to date:
#### 4a: Webhook JSON auto-update workflow
```yaml
# .github/workflows/update-webhooks.yml
name: Update webhook definitions
on:
schedule:
- cron: '0 0 * * 1' # Weekly on Monday
workflow_dispatch: # Manual trigger
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run update-webhooks
- name: Create PR if changes
uses: peter-evans/create-pull-request@v5
with:
title: "chore: Update webhook definitions"
body: |
Automated update from `rest-api-description` package.
This PR was created automatically by the update-webhooks workflow.
branch: auto/update-webhooks
delete-branch: true # Delete old branch, creates fresh PR each time
commit-message: "chore: Update webhook definitions"
```
#### 4b: Schedule/workflow_call JSON auto-update workflow
Create a workflow that runs an actual scheduled workflow and captures `github.event`:
```yaml
# .github/workflows/capture-schedule-payload.yml
name: Capture schedule event payload
on:
schedule:
- cron: '0 0 1 * *' # Monthly on the 1st
workflow_dispatch:
jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Capture github.event
run: |
echo '${{ toJSON(github.event) }}' > /tmp/schedule-event.json
# Strip to just property structure (values → null)
node -e "
const fs = require('fs');
const strip = (o) => {
if (Array.isArray(o)) return o.length ? [strip(o[0])] : [];
if (o && typeof o === 'object') return Object.fromEntries(
Object.entries(o).map(([k,v]) => [k, strip(v)])
);
return null;
};
const data = JSON.parse(fs.readFileSync('/tmp/schedule-event.json'));
const stripped = strip(data);
fs.writeFileSync(
'languageservice/src/context-providers/events/schedule.json',
JSON.stringify(stripped, null, 2)
);
"
- name: Create PR if changes
uses: peter-evans/create-pull-request@v5
with:
title: "chore: Update schedule.json payload structure"
body: |
Captured fresh `github.event` structure from a real scheduled workflow run.
This ensures autocomplete suggestions match the actual event payload.
branch: auto/update-schedule-json
delete-branch: true
commit-message: "chore: Update schedule.json from live event"
```
#### 4c: Workflow_call payload capture
Similar approach - create a reusable workflow that calls itself and captures `github.event`:
```yaml
# .github/workflows/capture-workflow-call-payload.yml
name: Capture workflow_call event payload
on:
workflow_call:
workflow_dispatch:
jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Capture and update workflow_call.json
if: github.event_name == 'workflow_call'
run: |
# Similar to schedule capture above
echo '${{ toJSON(github.event) }}' | node -e "..." > workflow_call.json
- name: Trigger self as reusable workflow
if: github.event_name == 'workflow_dispatch'
uses: ./.github/workflows/capture-workflow-call-payload.yml
```
**Benefits:**
- JSON files stay up to date automatically
- PRs are created for review (not auto-merged)
- Captures real event structures, not guessed samples
- Weekly/monthly schedule catches GitHub API changes
## Validation Stages Analysis
The current `validate()` function does everything in one pass. We could split it into stages that load progressively:
### Current Loading Cascade
```
validate() called
└─ imports workflow-parser
└─ imports workflow-v1.0.json (112KB) ← loaded immediately
└─ parseWorkflow() → YAML parse + schema validation
└─ additionalValidations()
└─ getContext() → imports github.ts
└─ imports eventPayloads.ts
└─ imports webhooks.json (6.2MB) ← loaded immediately
```
### Potential Validation Stages
| Stage | What it validates | Data needed | Size |
|-------|-------------------|-------------|------|
| **1. YAML Syntax** | Valid YAML? Quotes closed? Indentation? | YAML parser (bundled) | ~0 |
| **2. Workflow Schema** | Valid `jobs:`, `steps:`, `runs-on:`? | `workflow-v1.0.json` | 112KB |
| **3. Expression Syntax** | Valid `${{ }}` syntax? Functions exist? | Expression parser | ~0 |
| **4. Context Validation** | `github.sha`, `env.FOO` exist? | Just code | ~0 |
| **5. Event Payload Validation** | `github.event.pull_request.title` exists? | `webhooks.json` | 6.2MB |
### Key Insight
Stages 1-4 can run with minimal data (~112KB). Only Stage 5 needs the 6.2MB webhook data.
**Expression syntax** (`${{ secrets.FOO }}`) is different from **event payload validation** (`${{ github.event.issue.number }}`):
- Expression syntax: Is this a valid expression? Does the function exist?
- Event payload: Does this specific property exist on the `pull_request` event?
### Options for Progressive Loading
**Option A: Lazy load webhooks.json (simplest)**
```typescript
// eventPayloads.ts - defer import until first use
let webhooksData: Webhooks | null = null;
async function getWebhooks() {
if (!webhooksData) {
const { default: data } = await import("./webhooks.json");
webhooksData = data;
}
return webhooksData;
}
```
- Pro: Minimal code changes
- Con: Still blocks when github.event.* is first accessed
**Option B: Multi-pass validation in languageservice**
```typescript
// New exports from @actions/languageservice
export { validateSchema } from "./validate-schema"; // Fast
export { validateExpressions } from "./validate-expressions"; // Needs webhooks
export { validate } from "./validate"; // Combined (current)
```
- Pro: Clean API, consumer controls loading
- Con: More work, API change
**Option C: Multi-pass validation in github-ui**
```typescript
// github-ui can show partial results
const schemaErrors = await validate(doc); // Returns what it can immediately
// Later, more errors may arrive as webhooks.json loads
```
- Pro: No languageservice changes
- Con: Complex state management in consumer
### Recommendation
1. **Phase 1**: Minify + strip unused data (reduce 6.2MB → ~1.2MB)
2. **Phase 2**: Lazy load webhooks.json in `eventPayloads.ts`
3. **Phase 3** (future): Consider multi-pass API if needed
The lazy loading approach gives 90% of the benefit with 10% of the complexity.
## Side Effects Analysis
Need to verify the packages have no side effects before adding `"sideEffects": false`:
- [x] `@actions/languageservice` - Has ONE file with side effects
- [x] `@actions/workflow-parser` - ✅ No side effects
- [x] `@actions/expressions` - ✅ No side effects
Common side effects to look for:
- Top-level function calls (not just definitions)
- Modifying global objects (`Object.prototype`, `window`, etc.)
- Polyfills
- CSS imports (not applicable here)
### JSON Files Imported at Top Level
| Package | File | JSON Imported | Size | Has Side Effects? |
|---------|------|---------------|------|-------------------|
| languageservice | `eventPayloads.ts` | `webhooks.json` | 6.2 MB | ⚠️ YES (mutation) |
| languageservice | `eventPayloads.ts` | `objects.json` | 948 KB | ⚠️ YES (mutation) |
| languageservice | `eventPayloads.ts` | `schedule.json` | 6 KB | ⚠️ YES (mutation) |
| languageservice | `eventPayloads.ts` | `workflow_call.json` | 8 KB | ⚠️ YES (mutation) |
| languageservice | `descriptions.ts` | `descriptions.json` | 20 KB | ❌ No |
| workflow-parser | `workflow-schema.ts` | `workflow-v1.0.json` | 112 KB | ❌ No |
| expressions | (none) | (none) | - | ❌ No |
### Findings
**`@actions/expressions`** - ✅ No side effects
- No JSON imports
- No top-level code execution
- Can use `"sideEffects": false`
**`@actions/workflow-parser`** - ✅ No side effects
- `workflow-schema.ts` imports `workflow-v1.0.json` at top level BUT:
- Only exports a function `getWorkflowSchema()` with lazy initialization
- No top-level function calls or mutations
- Can use `"sideEffects": false`
**`@actions/languageservice`** - ⚠️ HAS ONE FILE with side effects
`descriptions.ts` - ❌ No side effects
- Imports `descriptions.json` (20KB) at top level
- Only exports functions, no top-level execution
`eventPayloads.ts` - ⚠️ HAS SIDE EFFECTS
```typescript
// Lines 3-7: JSON imports at top level (7.2MB total)
import webhookObjects from "./objects.json";
import webhooks from "./webhooks.json";
import schedule from "./schedule.json";
import workflow_call from "./workflow_call.json";
// Lines 85-93: Executes at module load time, mutates data
getWebhookPayload("workflow_dispatch", "default");
const inputs = webhookPayloads?.["workflow_dispatch"]?.["default"].bodyParameters.find(p => p.name === "inputs");
if (inputs) {
delete inputs.childParamsGroups;
}
```
### Recommended `sideEffects` Configuration
**`expressions/package.json`:**
```json
"sideEffects": false
```
**`workflow-parser/package.json`:**
```json
"sideEffects": false
```
**`languageservice/package.json`:**
```json
"sideEffects": ["./dist/context-providers/events/eventPayloads.js"]
```
**Impact:** Allows webpack to tree-shake unused exports. Without this, webpack assumes all imports may have side effects and keeps everything.
### Optional: Refactor `eventPayloads.ts` to Remove Side Effects
To allow `"sideEffects": false` for the entire languageservice package, refactor the mutation code:
```typescript
// Before: Top-level mutation
getWebhookPayload("workflow_dispatch", "default");
const inputs = webhookPayloads?.["workflow_dispatch"]?.["default"].bodyParameters.find(p => p.name === "inputs");
if (inputs) {
delete inputs.childParamsGroups;
}
// After: Lazy initialization inside function
let initialized = false;
function ensureInitialized() {
if (initialized) return;
initialized = true;
// ... mutation code here
}
export function getEventPayload(...) {
ensureInitialized();
// ... rest of function
}
```
This would allow full tree-shaking AND defer the 7.2MB JSON load until first use.
+153
View File
@@ -0,0 +1,153 @@
# Bundle Size Optimization Plan
## Goal
Reduce `@actions/languageservice` package size from **7.9 MB** to **~1.5 MB** (80% reduction).
## Summary
| Phase | Change | Savings | Effort |
|-------|--------|---------|--------|
| 1a | Minify all JSON | 60% | Low |
| 1b | Strip unused fields | 10% | Low |
| 1c | Drop unused events | 19% | Low |
| 2 | Lazy-load webhooks.json (optional) | Faster initial load | Medium |
## Phase 1: Optimize JSON files
### What each JSON file is used for
| File | Package | Purpose |
|------|---------|---------|
| `webhooks.json` | languageservice | Autocomplete/validation for `github.event.*` expressions. Contains event payload schemas from GitHub's REST API. |
| `objects.json` | languageservice | Deduplicated parameter definitions shared across webhooks (reduces duplication in webhooks.json). |
| `workflow-v1.0.json` | workflow-parser | Workflow schema defining valid YAML structure (`jobs`, `steps`, `runs-on`, event triggers, etc.). |
| `descriptions.json` | languageservice | Hover descriptions for contexts (`github`, `env`, `secrets`) and built-in functions (`format`, `contains`, etc.). |
| `schedule.json` | languageservice | Sample `github.event` payload for `on: schedule` trigger (not a real webhook, manually authored). |
| `workflow_call.json` | languageservice | Sample `github.event` payload for `on: workflow_call` trigger (not a real webhook, manually authored). |
### Impact table
| File | Original | Strip | Drop | Minify | Gzip | All (no Gzip) | All (w/ Gzip) |
|------|----------|-------|------|--------|------|---------------|---------------|
| `webhooks.json` | 6.2 MB | 5.6 MB | 5.0 MB | 2.4 MB | 188 KB | **1.0 MB** | **50 KB** |
| `objects.json` | 948 KB | N/A | 770 KB | 460 KB | 36 KB | **180 KB** | **18 KB** |
| `workflow-v1.0.json` | 112 KB | N/A | N/A | 70 KB | 13 KB | **70 KB** | **12 KB** |
| `descriptions.json` | 18 KB | N/A | N/A | 17 KB | 3 KB | **17 KB** | **3 KB** |
| `schedule.json` | 5.7 KB | N/A | N/A | 5.1 KB | 1 KB | **5.1 KB** | **1 KB** |
| `workflow_call.json` | 7.3 KB | N/A | N/A | 6.5 KB | 1 KB | **6.5 KB** | **1 KB** |
| **Total** | **7.3 MB** | | | | **~240 KB** | **~1.3 MB** | **~85 KB** |
- **Strip** = Remove unused fields (`summary`, `availability`, `category`, `action`)
- **Drop** = Remove 31 non-trigger events (`installation`, `star`, `team`, etc.)
- **Minify** = Remove whitespace (`JSON.stringify(data)` instead of `JSON.stringify(data, null, 2)`)
- **Gzip** = Network transfer size (free - handled automatically by browser/server)
### 1a. Minify all JSON files
**Generated files** (`webhooks.json`, `objects.json`):
- Update `languageservice/script/webhooks/index.ts`
- These are generated via `npm run update-webhooks` from GitHub's REST API spec
- Use `JSON.stringify(data)` instead of `JSON.stringify(data, null, 2)`
**Hand-authored files** (`workflow-v1.0.json`, `descriptions.json`, `schedule.json`, `workflow_call.json`):
- Add minification step to build scripts
### 1b. Strip unused fields from webhooks.json
Remove before writing:
- `summary`
- `availability`
- `category`
- `action`
### 1c. Drop non-trigger events from webhooks.json
Keep only events that can trigger workflows ([docs](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows)). Drop 31 events:
```
code_scanning_alert, commit_comment, dependabot_alert, deploy_key,
github_app_authorization, installation, installation_repositories,
installation_target, marketplace_purchase, member, membership, meta,
org_block, organization, package, ping, projects_v2, projects_v2_item,
pull_request_review_thread, repository, repository_import,
repository_vulnerability_alert, secret_scanning_alert,
secret_scanning_alert_location, security_advisory, security_and_analysis,
sponsorship, star, team, team_add, workflow_job
```
**Expected result:** Total JSON 7.3 MB → ~1.3 MB (82% reduction)
---
## Phase 2: Lazy loading (optional)
Refactor `eventPayloads.ts` to load JSON on first use:
```typescript
let webhooksData: Webhooks | null = null;
async function getWebhooks() {
if (!webhooksData) {
const { default: data } = await import("./webhooks.json");
webhooksData = hydrate(data);
}
return webhooksData;
}
```
**Benefit:** Faster initial load when `github.event.*` isn't used.
---
## Current github-ui architecture
github-ui lazy-loads the language service via dynamic import:
```typescript
// workflow-editor-next.ts
let languageServicePromise: Promise<typeof import('./workflow-editor-language-service')> | null = null
async function getLanguageServiceModule() {
if (!languageServicePromise) {
languageServicePromise = import('./workflow-editor-language-service')
}
return languageServicePromise
}
```
**What this means:**
- The language service is only loaded when the workflow editor needs autocomplete/hover/validation
- Webpack code-splits it into a separate chunk
- The ~7.9 MB package is NOT loaded on initial page load
**Why Phase 1 is the priority:**
- When the language service chunk IS loaded, it still loads all 7.3 MB of JSON
- Reducing JSON to ~1.3 MB directly reduces this chunk size
- No changes needed in github-ui - the benefit is automatic
---
## Not doing
- **Tree-shaking / `sideEffects`** - github-ui imports `complete`, `hover`, and `validate` together, and all three depend on the same webhook JSON. Tree-shaking can't eliminate any of it.
- **Replacing dependencies** - `yaml` and `cronstrue` are appropriately sized
- **Multi-pass validation API** - Too complex for the benefit
- **Further deduplication** - Current object deduplication is sufficient
---
## Future considerations
- **`workflow_call.json` may be incorrect** - For `on: workflow_call`, `github.event` is inherited from the calling workflow (could be push, pull_request, etc.). The current file shows generic properties which may be misleading for autocomplete. Consider returning `Null` for all modes or removing the file entirely.
---
## Success metrics
| Metric | Before | After |
|--------|--------|-------|
| `webhooks.json` | 6.2 MB | ~1.2 MB |
| `objects.json` | 948 KB | ~225 KB |
| Total package (disk) | 7.9 MB | ~1.5 MB |
| npm tarball (gzipped) | 368 KB | ~80 KB |
+35
View File
@@ -0,0 +1,35 @@
# JSON Optimization Summary
| File | Original | Strip | Minify | Gzip | Strip+Minify | Minify+Gzip | Strip+Minify+Gzip |
|------|----------|-------|--------|------|--------------|-------------|-------------------|
| `webhooks.json` | 4.1 MB | 3.7 MB | 1.6 MB | 188 KB | 1.4 MB | 84 KB | 68 KB |
| `objects.json` | 666 KB | N/A | 325 KB | 36 KB | 325 KB | 22 KB | 22 KB |
| **Total** | **4.78 MB** | - | **1.95 MB** | **224 KB** | **1.77 MB** | **106 KB** | **91 KB** |
**Stripping removes:** `summary`, `availability`, `category`, `action` fields from webhooks.json (unused by language service)
## workflow-v1.0.json (hand-authored schema)
| File | Original | Minify | Gzip | Minify+Gzip |
|------|----------|--------|------|-------------|
| `workflow-v1.0.json` | 91 KB | 69 KB | 13 KB | 12 KB |
**Note:** No stripping applicable - this is a hand-authored schema where all fields are used.
## Recommended Action
**For webhooks.json and objects.json:** Strip + Minify
- Modify `languageservice/script/webhooks/index.ts` to:
1. Strip unused fields (`summary`, `availability`, `category`, `action`) before writing
2. Use `JSON.stringify(obj)` instead of `JSON.stringify(obj, null, 2)` to minify
- Gzip is handled automatically by github-ui's production server
**For workflow-v1.0.json:** Minify at build time
- Add a build step to minify the JSON before publishing
**Expected savings:**
- npm package size: 4.78 MB → 1.77 MB (63% reduction)
- Network transfer (gzip): 224 KB → 91 KB (59% reduction)
-152
View File
@@ -1,152 +0,0 @@
# Using GitHub Actions Language Server in Neovim
## Prerequisites
- Node.js 18+
- Neovim 0.11+ with the new LSP config format
## Setup Options
### Option 1: Install from npm (Recommended)
Once published, you can install globally:
```bash
npm install -g @actions/languageserver
```
Then configure Neovim to use the installed binary:
```lua
-- ~/.config/nvim/lsp/actionsls.lua
return {
cmd = { "actions-languageserver" },
filetypes = { "yaml.ghaction" }, -- GitHub Actions workflow files only
root_markers = { ".git" },
init_options = {
sessionToken = vim.fn.system("gh auth token"):gsub("%s+", ""),
logLevel = "info",
},
}
```
**Note:** This requires the package to be published to npm first.
### Option 2: Local Development Build
For development or if the npm package isn't published yet:
### 1. Clone and build
```bash
git clone https://github.com/actions/languageservices.git
cd languageservices
npm install
npm run build --workspaces --if-present
```
### 2. Bundle the server
The server needs to be bundled into a single file to avoid ESM module resolution issues:
```bash
cd languageserver
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node18 \
--format=cjs \
--outfile=dist/server-bundled.cjs \
--external:vscode \
--loader:.json=json
```
This creates `dist/server-bundled.cjs` (~5.6MB) that contains the entire server.
### 3. Configure Neovim
Create `~/.config/nvim/lsp/actionsls.lua`:
```lua
return {
cmd = {
"/absolute/path/to/languageservices/languageserver/bin/actions-languageserver",
},
filetypes = { "yaml.ghaction" }, -- GitHub Actions workflow files only
root_markers = { ".git" },
init_options = {
sessionToken = vim.fn.system("gh auth token"):gsub("%s+", ""),
logLevel = "info",
},
}
```
**Important:** Replace `/absolute/path/to/languageservices` with your actual clone path.
## Filetype Detection for GitHub Actions Workflows
To ensure the LSP only runs on GitHub Actions workflow files (not all YAML files), set up filetype detection:
**Option A:** In `~/.config/nvim/init.lua`:
```lua
vim.api.nvim_create_autocmd({"BufRead", "BufNewFile"}, {
pattern = ".github/workflows/*.{yml,yaml}",
callback = function()
vim.bo.filetype = "yaml.ghaction"
end,
})
```
**Option B:** Create `~/.config/nvim/ftdetect/ghaction.vim`:
```vim
au BufRead,BufNewFile .github/workflows/*.yml,*.yaml setfiletype yaml.ghaction
```
This sets the filetype to `yaml.ghaction` for files in `.github/workflows/`, matching the `filetypes` setting in your LSP config.
### 4. Enable the LSP in your init.lua
Add to your Neovim configuration:
```lua
vim.lsp.enable('actionsls')
```
### 5. Restart Neovim
Open any `.github/workflows/*.yml` file. The filetype detection will set it to `yaml.ghaction`, and the language server will attach automatically.
## Files Created
- `languageserver/dist/server-bundled.cjs` - Bundled server (~5.6MB)
- `languageserver/bin/actions-languageserver` - Shell wrapper script
The `dist/` directory is gitignored; you'll need to rebuild after pulling updates.
## Troubleshooting
Check if the server is running:
```vim
:lua =vim.lsp.get_clients()
```
View LSP logs:
```bash
tail -f ~/.local/state/nvim/lsp.log
```
Manually start the server to test:
```vim
:lua vim.lsp.start({name='actionsls', cmd={'/path/to/bin/actions-languageserver'}, root_dir=vim.fn.getcwd(), init_options={sessionToken='', logLevel='info'}})
```
## Notes
- The main code change is in `languageserver/src/index.ts` to use dynamic imports, avoiding loading browser modules in Node.js
- The bundling step is necessary because TypeScript outputs ESM with bare imports that Node.js can't resolve
- Only workflow files in git repositories will activate the LSP (due to `root_markers = { ".git" }`)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.20",
"version": "0.3.22",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -1,2 +0,0 @@
#!/usr/bin/env node
import "../dist/cli.bundle.cjs";
+3 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.20",
"version": "0.3.22",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -32,7 +32,6 @@
},
"scripts": {
"build": "tsc --build tsconfig.build.json",
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
@@ -43,12 +42,9 @@
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"watch": "tsc --build tsconfig.build.json --watch"
},
"bin": {
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@actions/languageservice": "^0.3.22",
"@actions/workflow-parser": "^0.3.22",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -65,7 +61,6 @@
"@types/jest": "^29.0.3",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"esbuild": "^0.27.1",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
-10
View File
@@ -1,10 +0,0 @@
#!/bin/bash
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node18 \
--format=cjs \
--outfile=dist/server-bundled.cjs \
--external:vscode \
--loader:.json=json
+1 -17
View File
@@ -1,11 +1,8 @@
import {documentLinks, getCodeActions, hover, validate, ValidationConfig} from "@actions/languageservice";
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem,
Connection,
DocumentLink,
@@ -75,9 +72,6 @@ export function initConnection(connection: Connection) {
hoverProvider: true,
documentLinkProvider: {
resolveProvider: false
},
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
}
}
};
@@ -164,16 +158,6 @@ export function initConnection(connection: Connection) {
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
});
connection.onCodeAction(async (params: CodeActionParams): Promise<CodeAction[]> => {
return timeOperation("codeAction", async () => {
return getCodeActions({
uri: params.textDocument.uri,
diagnostics: params.context.diagnostics,
only: params.context.only
});
});
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
+3 -3
View File
@@ -1,12 +1,12 @@
import { Connection } from "vscode-languageserver";
import {Connection} from "vscode-languageserver";
import {
BrowserMessageReader,
BrowserMessageWriter,
createConnection as createBrowserConnection
} from "vscode-languageserver/browser";
import { createConnection as createNodeConnection } from "vscode-languageserver/node";
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
import { initConnection } from "./connection";
import {initConnection} from "./connection";
/** Helper function determining whether we are executing with node runtime */
function isNode(): boolean {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.20",
"version": "0.3.22",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -44,8 +44,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.20",
"@actions/workflow-parser": "^0.3.20",
"@actions/expressions": "^0.3.22",
"@actions/workflow-parser": "^0.3.22",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
-54
View File
@@ -1,54 +0,0 @@
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
import {CodeActionContext, CodeActionProvider} from "./types";
import {quickfixProviders} from "./quickfix";
// Aggregate all providers by kind
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
[CodeActionKind.QuickFix, quickfixProviders]
// [CodeActionKind. Refactor, refactorProviders],
// [CodeActionKind.Source, sourceProviders],
// etc
]);
export interface CodeActionConfig {
// TODO: actionsMetadataProvider, fileProvider, etc.
}
export interface CodeActionParams {
uri: string;
diagnostics: Diagnostic[];
only?: string[];
}
export function getCodeActions(params: CodeActionParams, config?: CodeActionConfig): CodeAction[] {
const actions: CodeAction[] = [];
const context: CodeActionContext = {
uri: params.uri
};
// Filter to requested kinds, or use all if none specified
const requestedKinds = params.only;
const kindsToCheck = requestedKinds
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
: [...providersByKind.keys()];
for (const diagnostic of params.diagnostics) {
for (const kind of kindsToCheck) {
const providers = providersByKind.get(kind) ?? [];
for (const provider of providers) {
if (provider.diagnosticCodes.includes(diagnostic.code)) {
const action = provider.createCodeAction(context, diagnostic);
if (action) {
action.kind = kind;
action.diagnostics = [diagnostic];
actions.push(action);
}
}
}
}
}
return actions;
}
export type {CodeActionContext, CodeActionProvider} from "./types";
@@ -1,67 +0,0 @@
import { CodeAction, TextEdit } from "vscode-languageserver-types";
import { CodeActionContext, CodeActionProvider } from "../types";
import { DiagnosticCode, MissingInputsDiagnosticData } from "../../validate-action";
export const addMissingInputsProvider: CodeActionProvider = {
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
createCodeAction(context, diagnostic): CodeAction | undefined {
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
if (!data) {
return undefined;
}
const edits = createInputEdits(data);
if (!edits) {
return undefined;
}
const inputNames = data.missingInputs.map(i => i.name).join(", ");
return {
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
edit: {
changes: {
[context.uri]: edits,
},
},
};
},
};
function createInputEdits(data: MissingInputsDiagnosticData): TextEdit[] | undefined {
const edits: TextEdit[] = [];
if (data.hasWithKey && data.withIndent !== undefined) {
// `with:` exists - use its indentation + 2 for inputs
const inputIndent = " ".repeat(data.withIndent + 2);
const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
return `${inputIndent}${input.name}: ${value}`;
});
edits.push({
range: { start: data.insertPosition, end: data.insertPosition },
newText: inputLines.map(line => line + "\n").join(""),
});
} else {
// No `with:` key - `with:` at step indentation, inputs at step indentation + 2
const withIndent = " ".repeat(data.stepIndent);
const inputIndent = " ".repeat(data.stepIndent + 2);
const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
return `${inputIndent}${input.name}: ${value}`;
});
const newText = [`${withIndent}with:\n`, ...inputLines.map(line => `${line}\n`)].join("");
edits.push({
range: { start: data.insertPosition, end: data.insertPosition },
newText,
});
}
return edits;
}
@@ -1,4 +0,0 @@
import {CodeActionProvider} from "../types";
import {addMissingInputsProvider} from "./add-missing-inputs";
export const quickfixProviders: CodeActionProvider[] = [addMissingInputsProvider];
@@ -1,90 +0,0 @@
import * as path from "path";
import {fileURLToPath} from "url";
import {loadTestCases, runTestCase} from "./runner";
import {ValidationConfig} from "../../validate";
import {ActionMetadata, ActionReference} from "../../action";
import {clearCache} from "../../utils/workflow-cache";
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Mock action metadata provider for tests
const validationConfig: ValidationConfig = {
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
const metadata: Record<string, ActionMetadata> = {
"actions/cache@v1": {
name: "Cache",
description: "Cache dependencies",
inputs: {
path: {
description: "A list of files to cache",
required: true
},
key: {
description: "Cache key",
required: true
},
"restore-keys": {
description: "Restore keys",
required: false
}
}
},
"actions/setup-node@v3": {
name: "Setup Node",
description: "Setup Node. js",
inputs: {
"node-version": {
description: "Node version",
required: true,
default: "16"
}
}
}
};
return Promise.resolve(metadata[key]);
}
}
};
// Point to the source testdata directory
const testdataDir = path.join(__dirname, "testdata");
beforeEach(() => {
clearCache();
});
describe("code action golden tests", () => {
const testCases = loadTestCases(testdataDir);
if (testCases.length === 0) {
it.todo("no test cases found - add . yml files to testdata/");
return;
}
for (const testCase of testCases) {
it(testCase.name, async () => {
const result = await runTestCase(testCase, validationConfig);
if (!result.passed) {
let errorMessage = result.error || "Test failed";
if (result.expected !== undefined && result.actual !== undefined) {
errorMessage += "\n\n";
errorMessage += "=== EXPECTED (golden file) ===\n";
errorMessage += result.expected;
errorMessage += "\n\n";
errorMessage += "=== ACTUAL ===\n";
errorMessage += result.actual;
}
throw new Error(errorMessage);
}
});
}
});
@@ -1,227 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import { TextEdit } from "vscode-languageserver-types";
import { TextDocument } from "vscode-languageserver-textdocument";
import { validate, ValidationConfig } from "../../validate";
import { getCodeActions, CodeActionParams } from "../index";
// Marker pattern: # want "diagnostic message" fix="code-action-name"
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
export interface TestCase {
name: string;
inputPath: string;
goldenPath: string;
input: string;
golden: string;
markers: Marker[];
}
export interface Marker {
line: number;
message: string;
fix?: string;
}
export interface TestResult {
name: string;
passed: boolean;
error?: string;
expected?: string;
actual?: string;
}
/**
* Parse markers from input file content
*/
export function parseMarkers(content: string): Marker[] {
const lines = content.split("\n");
const markers: Marker[] = [];
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(MARKER_PATTERN);
if (match) {
markers.push({
line: i,
message: match[1],
fix: match[2]
});
}
}
return markers;
}
/**
* Strip markers from content (for processing)
*/
export function stripMarkers(content: string): string {
return content
.split("\n")
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
.join("\n");
}
/**
* Load all test cases from a testdata directory
*/
export function loadTestCases(testdataDir: string): TestCase[] {
const testCases: TestCase[] = [];
function walkDir(dir: string) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(". golden.yml")) {
const goldenPath = fullPath.replace(".yml", ".golden.yml");
if (fs.existsSync(goldenPath)) {
const input = fs.readFileSync(fullPath, "utf-8");
const golden = fs.readFileSync(goldenPath, "utf-8");
testCases.push({
name: path.relative(testdataDir, fullPath),
inputPath: fullPath,
goldenPath,
input,
golden,
markers: parseMarkers(input)
});
}
}
}
}
walkDir(testdataDir);
return testCases;
}
/**
* Apply text edits to a document
*/
export function applyEdits(content: string, edits: TextEdit[]): string {
// Sort edits in reverse order by position to apply from bottom to top
const sortedEdits = [...edits].sort((a, b) => {
if (b.range.start.line !== a.range.start.line) {
return b.range.start.line - a.range.start.line;
}
return b.range.start.character - a.range.start.character;
});
const lines = content.split("\n");
for (const edit of sortedEdits) {
const startLine = edit.range.start.line;
const startChar = edit.range.start.character;
const endLine = edit.range.end.line;
const endChar = edit.range.end.character;
const before = lines[startLine].slice(0, startChar);
const after = lines[endLine].slice(endChar);
const newLines = edit.newText.split("\n");
newLines[0] = before + newLines[0];
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
lines.splice(startLine, endLine - startLine + 1, ...newLines);
}
return lines.join("\n");
}
/**
* Run a single test case
*/
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
const strippedInput = stripMarkers(testCase.input);
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
// 1. Validate and get diagnostics
const diagnostics = await validate(document, validationConfig);
// 2. Verify all expected diagnostics are present
const missingDiagnostics: string[] = [];
for (const marker of testCase.markers) {
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
console.log(found);
if (!found) {
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
}
}
if (missingDiagnostics.length > 0) {
return {
name: testCase.name,
passed: false,
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
"\n "
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
};
}
// 3. Collect all edits from all matching code actions
const allEdits: TextEdit[] = [];
for (const marker of testCase.markers) {
if (!marker.fix) {
continue;
}
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!diagnostic) {
continue; // Already reported above
}
const params: CodeActionParams = {
uri: document.uri,
diagnostics: [diagnostic]
};
const actions = getCodeActions(params);
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
if (!matchingAction) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${actions.map(a => a.title).join(", ") || "(none)"
}`
};
}
if (!matchingAction.edit?.changes) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" has no edits`
};
}
const edits = matchingAction.edit.changes[document.uri] || [];
allEdits.push(...edits);
}
// 4. Apply all edits and compare to golden file
const actualOutput = applyEdits(strippedInput, allEdits);
const expectedOutput = testCase.golden;
if (actualOutput.trim() !== expectedOutput.trim()) {
return {
name: testCase.name,
passed: false,
error: "Output does not match golden file",
expected: expectedOutput,
actual: actualOutput
};
}
return {
name: testCase.name,
passed: true
};
}
@@ -1,9 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -1,7 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -1,10 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: ${{ runner.os }}-
path: ""
key: ""
@@ -1,8 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
restore-keys: ${{ runner.os }}-
@@ -1,9 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -1,6 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
-21
View File
@@ -1,21 +0,0 @@
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
export interface CodeActionContext {
uri: string;
// TODO: add things like workflow template, parsed content, etc.
}
/**
* A provider that can produce a code action for a given diagnostic
*/
export interface CodeActionProvider {
/**
* The diagnostic codes this provider handles
*/
diagnosticCodes: (string | number | undefined)[];
/**
* Create a code action for the diagnostic, if applicable
*/
createCodeAction(context: CodeActionContext, diagnostic: Diagnostic): CodeAction | undefined;
}
@@ -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"
|
@@ -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,
-1
View File
@@ -5,4 +5,3 @@ export {hover} from "./hover";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log";
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
export {getCodeActions} from "./code-actions";
@@ -5,9 +5,9 @@ export const testFileProvider: FileProvider = {
// eslint-disable-next-line @typescript-eslint/require-await
getFileContent: async ref => {
switch (fileIdentifier(ref)) {
case "monalisa/octocat/workflow.yaml@main":
case "monalisa/octocat/.github/workflows/workflow.yaml@main":
return {
name: "monalisa/octocat/workflow.yaml",
name: "monalisa/octocat/.github/workflows/workflow.yaml",
content: `
on: workflow_call
jobs:
@@ -31,9 +31,9 @@ jobs:
`
};
case "./reusable-workflow.yaml":
case "./.github/workflows/reusable-workflow.yaml":
return {
name: "reusable-workflow.yaml",
name: ".github/workflows/reusable-workflow.yaml",
content: `
on: workflow_call
jobs:
@@ -44,9 +44,9 @@ jobs:
`
};
case "./reusable-workflow-with-inputs.yaml":
case "./.github/workflows/reusable-workflow-with-inputs.yaml":
return {
name: "reusable-workflow-with-inputs.yaml",
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
content: `
on:
workflow_call:
@@ -76,9 +76,9 @@ jobs:
`
};
case "./reusable-workflow-with-inputs-no-description.yaml":
case "./.github/workflows/reusable-workflow-with-inputs-no-description.yaml":
return {
name: "reusable-workflow-with-inputs.yaml",
name: ".github/workflows/reusable-workflow-with-inputs.yaml",
content: `
on:
workflow_call:
@@ -95,9 +95,9 @@ jobs:
`
};
case "./reusable-workflow-with-outputs.yaml":
case "./.github/workflows/reusable-workflow-with-outputs.yaml":
return {
name: "reusable-workflow-with-outputs.yaml",
name: ".github/workflows/reusable-workflow-with-outputs.yaml",
content: `
on:
workflow_call:
+12 -67
View File
@@ -1,30 +1,12 @@
import { isMapping } from "@actions/workflow-parser";
import { isActionStep } from "@actions/workflow-parser/model/type-guards";
import { Step } from "@actions/workflow-parser/model/workflow-template";
import { ScalarToken } from "@actions/workflow-parser/templates/tokens/scalar-token";
import { TemplateToken } from "@actions/workflow-parser/templates/tokens/template-token";
import { Diagnostic, DiagnosticSeverity } from "vscode-languageserver-types";
import { ActionReference, parseActionReference } from "./action";
import { mapRange } from "./utils/range";
import { ValidationConfig } from "./validate";
export const DiagnosticCode = {
MissingRequiredInputs: "missing-required-inputs"
} as const;
export interface MissingInputsDiagnosticData {
action: ActionReference;
missingInputs: Array<{
name: string;
default?: string;
}>;
hasWithKey: boolean;
// Indentation of the `with:` key if present, or the step's base indentation
withIndent?: number;
stepIndent: number;
// Position where new content should be inserted
insertPosition: { line: number; character: number };
}
import {isMapping} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {parseActionReference} from "./action";
import {mapRange} from "./utils/range";
import {ValidationConfig} from "./validate";
export async function validateAction(
diagnostics: Diagnostic[],
@@ -53,7 +35,7 @@ export async function validateAction(
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const { key, value } of stepToken) {
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
@@ -63,7 +45,7 @@ export async function validateAction(
const stepInputs = new Map<string, ScalarToken>();
if (withToken && isMapping(withToken)) {
for (const { key } of withToken) {
for (const {key} of withToken) {
stepInputs.set(key.toString(), key);
}
}
@@ -101,47 +83,10 @@ export async function validateAction(
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
const stepIndent = stepToken.range ? stepToken.range.start.column - 1 : 0; // 0-indexed
const withIndent = withKey?.range ? withKey.range.start.column - 1 : undefined;
// Calculate insert position
// For withToken, we need to handle empty mappings specially - insert after the with: line
let insertPosition: { line: number; character: number };
if (withToken?.range) {
// Check if with: has any children by comparing start and end lines
const hasChildren = stepInputs.size > 0;
if (hasChildren) {
// Insert after the last child
insertPosition = { line: withToken.range.end.line - 1, character: 0 };
} else {
// Empty with: block - insert on the next line after with:
insertPosition = { line: withKey!.range!.end.line, character: 0 };
}
} else if (stepToken.range) {
insertPosition = { line: stepToken.range.end.line - 1, character: 0 };
} else {
insertPosition = { line: 0, character: 0 };
}
const diagnosticData: MissingInputsDiagnosticData = {
action,
missingInputs: missingRequiredInputs.map(([name, input]) => ({
name,
default: input.default
})),
hasWithKey: withKey !== undefined,
withIndent,
stepIndent,
insertPosition
};
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
message: message,
code: DiagnosticCode.MissingRequiredInputs,
data: diagnosticData
message: message
});
}
}
+3 -74
View File
@@ -249,28 +249,7 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
hasWithKey: true,
insertPosition: {
character: 0,
line: 9
},
missingInputs: [
{
default: undefined,
name: "path"
}
],
stepIndent: 6,
withIndent: 6
}
severity: DiagnosticSeverity.Error
}
]);
});
@@ -315,32 +294,7 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
hasWithKey: true,
insertPosition: {
character: 0,
line: 9
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
],
stepIndent: 6,
withIndent: 6
}
severity: DiagnosticSeverity.Error
}
]);
});
@@ -369,32 +323,7 @@ jobs:
line: 6
}
},
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
hasWithKey: false,
insertPosition: {
character: 0,
line: 7
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
],
stepIndent: 6,
withIndent: undefined
}
severity: DiagnosticSeverity.Error
}
]);
});
@@ -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/[email protected]
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/[email protected]': invalid character '..' in version",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 54}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version ending with dot", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1.
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1.': version cannot begin or end with a slash '/' or a dot '.'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 51}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version starting with slash", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@/v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@/v1': version cannot begin or end with a slash '/' or a dot '.'",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 51}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version ending with .lock", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@refs/heads/main.lock
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@refs/heads/main.lock': invalid version: refs/heads/main.lock",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 68}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version with whitespace", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1 && rm -rf
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1 && rm -rf': version cannot have whitespace",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 60}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("version with backslash", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/test.yml@v1\\1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo/.github/workflows/test.yml@v1\\1': invalid character '\\' in version",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 52}
},
code: "invalid-workflow-uses-format"
}
]);
});
});
describe("invalid owner/repo names", () => {
it("owner with invalid characters", async () => {
const input = `on: push
jobs:
test:
uses: owner*/repo/.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner*/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 51}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("repo with invalid characters", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo!name/.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner/repo!name/.github/workflows/test.yml@v1': repository name is invalid",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 55}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("owner with spaces", async () => {
const input = `on: push
jobs:
test:
uses: owner name/repo/.github/workflows/test.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message:
"Invalid workflow reference 'owner name/repo/.github/workflows/test.yml@v1': owner name must be a valid repository owner name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 55}
},
code: "invalid-workflow-uses-format"
}
]);
});
});
describe("invalid workflow filename", () => {
it("filename is just .yml", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/.yml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yml@v1': invalid workflow file name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 46}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("filename is just .yaml", async () => {
const input = `on: push
jobs:
test:
uses: owner/repo/.github/workflows/.yaml@v1
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference 'owner/repo/.github/workflows/.yaml@v1': invalid workflow file name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 47}
},
code: "invalid-workflow-uses-format"
}
]);
});
it("local workflow filename is just .yml", async () => {
const input = `on: push
jobs:
test:
uses: ./.github/workflows/.yml
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toEqual([
{
message: "Invalid workflow reference './.github/workflows/.yml': invalid workflow file name",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 3, character: 10},
end: {line: 3, character: 34}
},
code: "invalid-workflow-uses-format"
}
]);
});
});
});
});
@@ -43,7 +43,7 @@ on: push
jobs:
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.22"
}
+121 -667
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.20",
"version": "0.3.22",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -45,7 +45,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.20",
"@actions/expressions": "^0.3.22",
"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 {