Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16e22db75a | |||
| d954a19859 |
@@ -12,55 +12,18 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
- name: Use Node.js 16.15
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version: 16.15
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci --engine-strict
|
||||
- run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: npm run format-check -ws
|
||||
- run: npm run build -ws
|
||||
- run: npm run lint -ws
|
||||
- run: npm test -ws
|
||||
|
||||
check-generated:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Regenerate JSON files
|
||||
run: |
|
||||
cd languageservice && npm run update-webhooks && cd ..
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "ERROR: Generated files are out of date!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Please run the following commands locally and commit the changes:"
|
||||
echo ""
|
||||
echo " cd languageservice && npm run update-webhooks && cd .."
|
||||
echo " git add -A && git commit -m 'Regenerate JSON files'"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 16.x
|
||||
cache: "npm"
|
||||
scope: '@actions'
|
||||
|
||||
|
||||
+1
-9
@@ -2,12 +2,4 @@
|
||||
*/dist
|
||||
lerna-debug.log
|
||||
node_modules
|
||||
.DS_Store
|
||||
|
||||
# Minified JSON (generated at build time)
|
||||
*.min.json
|
||||
|
||||
# Intermediate JSON for size comparison (generated by update-webhooks --all)
|
||||
*.all.json
|
||||
*.drop.json
|
||||
*.strip.json
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
@@ -8,10 +8,6 @@ This repository contains multiple npm packages for working with GitHub Actions w
|
||||
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
|
||||
- [browser-playground](./browser-playground) - Browser-based playground for the language service
|
||||
|
||||
## Documentation
|
||||
|
||||
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
|
||||
|
||||
### Note
|
||||
|
||||
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
# JSON Data Files
|
||||
|
||||
This document describes the JSON data files used by the language service packages and how they are maintained.
|
||||
|
||||
## Overview
|
||||
|
||||
The language service uses several JSON files containing schema definitions, webhook payloads, and other metadata. To reduce bundle size, these files are:
|
||||
|
||||
1. **Optimized at generation time** — unused events are dropped, unused fields are stripped
|
||||
2. **Minified at build time** — whitespace is removed to produce `.min.json` files
|
||||
|
||||
The source `.json` files are human-readable and checked into the repository. The `.min.json` files are generated during build and gitignored.
|
||||
|
||||
## Files
|
||||
|
||||
### languageservice
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/context-providers/events/webhooks.json` | Webhook event payload schemas for autocompletion |
|
||||
| `src/context-providers/events/objects.json` | Deduplicated shared object definitions referenced by webhooks |
|
||||
| `src/context-providers/events/schedule.json` | Schedule event context data |
|
||||
| `src/context-providers/events/workflow_call.json` | Reusable workflow call context data |
|
||||
| `src/context-providers/descriptions.json` | Context variable descriptions for hover |
|
||||
|
||||
### workflow-parser
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `src/workflow-v1.0.json` | Workflow YAML schema definition |
|
||||
|
||||
## Generation
|
||||
|
||||
### Webhooks and Objects
|
||||
|
||||
The `webhooks.json` and `objects.json` files are generated from the [GitHub REST API description](https://github.com/github/rest-api-description):
|
||||
|
||||
```bash
|
||||
cd languageservice
|
||||
npm run update-webhooks
|
||||
```
|
||||
|
||||
This script:
|
||||
1. Fetches webhook schemas from the GitHub API description
|
||||
2. **Validates** all events are categorized (fails if new events are found)
|
||||
3. **Drops** events that aren't valid workflow triggers (see [Dropped Events](#dropped-events))
|
||||
4. **Strips** unused fields like `description` and `summary` (see [Stripped Fields](#stripped-fields))
|
||||
5. **Deduplicates** shared object definitions into `objects.json`
|
||||
6. Writes the optimized, pretty-printed JSON files
|
||||
|
||||
### Handling New Webhook Events
|
||||
|
||||
When GitHub adds a new webhook event, the script will fail with an error like:
|
||||
|
||||
```
|
||||
ERROR: New webhook event(s) detected!
|
||||
|
||||
The following events are not categorized:
|
||||
- new_event_name
|
||||
|
||||
Action required:
|
||||
1. Check if the event is a valid workflow trigger
|
||||
2. Add the event to DROPPED_EVENTS or KEPT_EVENTS
|
||||
```
|
||||
|
||||
**To resolve:**
|
||||
|
||||
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
|
||||
|
||||
2. Edit `languageservice/script/webhooks/index.ts`:
|
||||
- Add to `KEPT_EVENTS` if it's a valid workflow trigger
|
||||
- Add to `DROPPED_EVENTS` if it's GitHub App or API-only
|
||||
|
||||
3. Run `npm run update-webhooks` and commit the changes
|
||||
|
||||
#### Viewing Full Unprocessed Data
|
||||
|
||||
To see all available fields and events before optimization:
|
||||
|
||||
```bash
|
||||
npm run update-webhooks -- --all
|
||||
```
|
||||
|
||||
This generates `webhooks.all.json` and `objects.all.json` (gitignored) containing the complete unprocessed data from the GitHub API.
|
||||
|
||||
### Other Files
|
||||
|
||||
The other JSON files (`schedule.json`, `workflow_call.json`, `descriptions.json`, `workflow-v1.0.json`) are manually maintained.
|
||||
|
||||
## Minification
|
||||
|
||||
At build time, all JSON files are minified (whitespace removed) to produce `.min.json` versions:
|
||||
|
||||
```bash
|
||||
npm run minify-json
|
||||
```
|
||||
|
||||
This runs automatically via `prebuild` and `pretest` hooks, so you don't need to run it manually.
|
||||
|
||||
The code imports the minified versions:
|
||||
|
||||
```ts
|
||||
import webhooks from "./events/webhooks.min.json"
|
||||
```
|
||||
|
||||
## CI Verification
|
||||
|
||||
CI verifies that generated source files are up-to-date:
|
||||
|
||||
1. Runs `npm run update-webhooks` to regenerate webhooks.json and objects.json
|
||||
2. Checks for uncommitted changes with `git diff --exit-code`
|
||||
|
||||
The `.min.json` files are generated at build time and are not committed to the repository.
|
||||
|
||||
If the build fails, run `cd languageservice && npm run update-webhooks` locally and commit the changes.
|
||||
|
||||
## Dropped Events
|
||||
|
||||
Webhook events that aren't valid workflow `on:` triggers are dropped (e.g., `installation`, `ping`, `member`, etc.). These are GitHub App or API-only events.
|
||||
|
||||
See `DROPPED_EVENTS` in `script/webhooks/index.ts` for the full list.
|
||||
|
||||
## Stripped Fields
|
||||
|
||||
Unused fields are stripped to reduce bundle size. For example:
|
||||
|
||||
```json
|
||||
// Before (from webhooks.all.json)
|
||||
{
|
||||
"type": "object",
|
||||
"name": "issue",
|
||||
"in": "body",
|
||||
"description": "The issue itself.",
|
||||
"isRequired": true,
|
||||
"childParamsGroups": [...]
|
||||
}
|
||||
|
||||
// After (webhooks.json)
|
||||
{
|
||||
"name": "issue",
|
||||
"description": "The issue itself.",
|
||||
"childParamsGroups": [...]
|
||||
}
|
||||
```
|
||||
|
||||
Only `name`, `description`, and `childParamsGroups` are kept — these are used for autocompletion and hover docs.
|
||||
|
||||
To compare all fields vs stripped, run `npm run update-webhooks -- --all` and diff the `.all.json` files against the regular ones.
|
||||
|
||||
See `EVENT_ACTION_FIELDS` and `BODY_PARAM_FIELDS` in `script/webhooks/index.ts` to modify what gets stripped.
|
||||
|
||||
## Schema Synchronization
|
||||
|
||||
The `workflow-v1.0.json` schema defines which activity types are valid for each workflow trigger event. A test in `workflow-parser/src/schema-sync.test.ts` verifies these stay in sync with `webhooks.json`.
|
||||
|
||||
### When the Test Fails
|
||||
|
||||
If the schema-sync test fails, you'll see an error like:
|
||||
|
||||
```
|
||||
Event "pull_request" is missing activity type "new_activity" in workflow-v1.0.json
|
||||
```
|
||||
|
||||
**To resolve:**
|
||||
|
||||
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows) to verify the activity type is a valid workflow trigger:
|
||||
- Find the event section (e.g., "pull_request")
|
||||
- Look at the "Activity types" table — it lists which types can be used in `on.<event>.types`
|
||||
- If the type is listed there, it's a valid workflow trigger
|
||||
- If the type only appears in webhook docs but NOT in the workflow trigger docs, it's webhook-only
|
||||
|
||||
2. If it IS a valid workflow trigger:
|
||||
- Edit `workflow-parser/src/workflow-v1.0.json`
|
||||
- Find the `<event>-activity-type` definition (e.g., `pull-request-activity-type`)
|
||||
- Add the new activity type to `allowed-values`
|
||||
- Update the `description` in `<event>-activity` to list all types
|
||||
- Run `npm test` to regenerate the minified JSON
|
||||
|
||||
3. If it is NOT a valid workflow trigger (webhook-only):
|
||||
- Edit `workflow-parser/src/schema-sync.test.ts`
|
||||
- Add the type to `WEBHOOK_ONLY` for that event
|
||||
|
||||
### Known Discrepancies
|
||||
|
||||
The test tracks several types of known discrepancies:
|
||||
|
||||
| Category | Purpose | Example |
|
||||
|----------|---------|---------|
|
||||
| `WEBHOOK_ONLY` | Types in webhooks that aren't valid workflow triggers | `check_suite.requested` |
|
||||
| `SCHEMA_ONLY` | Types valid for workflows but missing from webhooks | `registry_package.updated` |
|
||||
| `NAME_MAPPINGS` | Different names for the same concept | `project_column`: webhook uses `edited`, schema uses `updated` |
|
||||
|
||||
### Bidirectional Checking
|
||||
|
||||
The test checks both directions:
|
||||
- **webhooks → schema**: Ensures all webhook activity types are in the schema (or listed in `WEBHOOK_ONLY`)
|
||||
- **schema → webhooks**: Ensures the schema doesn't have types that don't exist in webhooks (or listed in `SCHEMA_ONLY` or `NAME_MAPPINGS`)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.25",
|
||||
"version": "0.3.22",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -44,7 +44,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 16.15"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.25",
|
||||
"version": "0.3.22",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -43,8 +43,8 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.25",
|
||||
"@actions/workflow-parser": "^0.3.25",
|
||||
"@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",
|
||||
@@ -52,7 +52,7 @@
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 16.15"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
|
||||
describe("contextProviders", () => {
|
||||
const mockCache = new TTLCache();
|
||||
const mockRepo: RepositoryContext = {
|
||||
id: 123,
|
||||
owner: "test-owner",
|
||||
name: "test-repo",
|
||||
organizationOwned: true,
|
||||
workspaceUri: "file:///workspace"
|
||||
};
|
||||
const mockWorkflowContext: WorkflowContext = {
|
||||
uri: "test.yaml",
|
||||
template: undefined
|
||||
};
|
||||
|
||||
describe("when client is undefined", () => {
|
||||
it("should return incomplete context for secrets", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should return incomplete context for vars", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should preserve defaultContext and mark as incomplete for secrets", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const defaultContext = new DescriptionDictionary();
|
||||
defaultContext.add("EXISTING_SECRET", new data.StringData("test"));
|
||||
|
||||
const result = await config.getContext("secrets", defaultContext, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBe(defaultContext);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
expect((result as DescriptionDictionary).get("EXISTING_SECRET")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return undefined for other contexts like steps", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("steps", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when both client and repo are undefined", () => {
|
||||
it("should return incomplete context for secrets", async () => {
|
||||
const config = contextProviders(undefined, undefined, mockCache);
|
||||
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should return incomplete context for vars", async () => {
|
||||
const config = contextProviders(undefined, undefined, mockCache);
|
||||
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,18 +15,7 @@ export function contextProviders(
|
||||
cache: TTLCache
|
||||
): ContextProviderConfig {
|
||||
if (!repo || !client) {
|
||||
// When GitHub client/repo is unavailable, return an incomplete dictionary
|
||||
// to avoid false "Context access might be invalid" warnings
|
||||
return {
|
||||
getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => {
|
||||
if (name === "secrets" || name === "vars") {
|
||||
const context = defaultContext || new DescriptionDictionary();
|
||||
context.complete = false;
|
||||
return Promise.resolve(context);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
};
|
||||
return {getContext: () => Promise.resolve(undefined)};
|
||||
}
|
||||
|
||||
const getContext = async (
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function getSecrets(
|
||||
if (isString(x.value)) {
|
||||
environmentName = x.value.value;
|
||||
} else {
|
||||
// this means we have a dynamic environment, in those situations we
|
||||
// this means we have a dynamic enviornment, in those situations we
|
||||
// want to make sure we skip doing secret validation
|
||||
secretsContext.complete = false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
@@ -63,43 +63,6 @@ it("returns default context when job is undefined", async () => {
|
||||
expect(stepsContext).toEqual(defaultContext);
|
||||
});
|
||||
|
||||
it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
const workflowContext = await createWorkflowContext(workflow, "build");
|
||||
const defaultContext = getDefaultStepsContext(workflowContext);
|
||||
|
||||
const stepsContext = await getStepsContext(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
new TTLCache(),
|
||||
defaultContext,
|
||||
workflowContext
|
||||
);
|
||||
|
||||
// Get the step context
|
||||
const stepContext = stepsContext?.get("cache-primes");
|
||||
expect(stepContext).toBeDefined();
|
||||
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
||||
|
||||
// Get the outputs - should be a dictionary, not null
|
||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||
expect(outputs).toBeDefined();
|
||||
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
||||
|
||||
// Outputs should be marked incomplete to allow dynamic outputs
|
||||
const outputsDict = outputs as DescriptionDictionary;
|
||||
expect(outputsDict.complete).toBe(false);
|
||||
|
||||
// Known outputs from action.yml should be present
|
||||
expect(outputsDict.get("cache-hit")).toBeDefined();
|
||||
});
|
||||
|
||||
it("adds action outputs", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
@@ -120,22 +83,17 @@ it("adds action outputs", async () => {
|
||||
);
|
||||
expect(stepsContext).toBeDefined();
|
||||
|
||||
// Create expected outputs dict with complete = false
|
||||
// (actions can have dynamic outputs beyond what's declared in action.yml)
|
||||
const expectedOutputs = new DescriptionDictionary({
|
||||
key: "cache-hit",
|
||||
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
|
||||
description: "A boolean value to indicate an exact match was found for the primary key"
|
||||
});
|
||||
expectedOutputs.complete = false;
|
||||
|
||||
expect(stepsContext).toEqual(
|
||||
new DescriptionDictionary({
|
||||
key: "cache-primes",
|
||||
value: new DescriptionDictionary(
|
||||
{
|
||||
key: "outputs",
|
||||
value: expectedOutputs
|
||||
value: new DescriptionDictionary({
|
||||
key: "cache-hit",
|
||||
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
|
||||
description: "A boolean value to indicate an exact match was found for the primary key"
|
||||
})
|
||||
},
|
||||
{
|
||||
key: "conclusion",
|
||||
|
||||
@@ -58,8 +58,6 @@ export async function getStepsContext(
|
||||
continue;
|
||||
}
|
||||
const outputsDict = new DescriptionDictionary();
|
||||
// Actions can have dynamic outputs beyond what's declared in action.yml
|
||||
outputsDict.complete = false;
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
outputsDict.add(key, new data.StringData(value.description), value.description);
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ export async function getVariables(
|
||||
return secretsContext;
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
|
||||
let environmentName: string | undefined;
|
||||
if (workflowContext?.job?.environment) {
|
||||
if (isString(workflowContext.job.environment)) {
|
||||
@@ -37,19 +35,14 @@ export async function getVariables(
|
||||
if (isString(x.key) && x.key.value === "name") {
|
||||
if (isString(x.value)) {
|
||||
environmentName = x.value.value;
|
||||
} else {
|
||||
// this means we have a dynamic environment, in those situations we want to skip validation
|
||||
variablesContext.complete = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if the expression is something like environment: ${{ ... }} then we want to skip validation
|
||||
variablesContext.complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
try {
|
||||
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ inputs:
|
||||
description: 'Repository name with owner. For example, actions/checkout'
|
||||
deprecationMessage: 'Use repository instead'
|
||||
runs:
|
||||
using: node24
|
||||
using: node16
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
|
||||
@@ -12,7 +12,7 @@ inputs:
|
||||
description: Repository name with owner. For example, actions/checkout
|
||||
default: \${{ github.repository }}
|
||||
runs:
|
||||
using: node24
|
||||
using: node16
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
@@ -231,7 +231,7 @@ inputs:
|
||||
description: 📦 Repository 📦 name with owner. For example, actions/checkout
|
||||
default: \${{ github.repository }}
|
||||
runs:
|
||||
using: node24
|
||||
using: node16
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.25",
|
||||
"version": "0.3.22",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -37,25 +37,22 @@
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
|
||||
"prebuild": "npm run minify-json",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"pretest": "npm run minify-json",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||
"update-webhooks": "npx tsx script/webhooks/index.ts",
|
||||
"update-webhooks": "ts-node-esm script/webhooks/index.ts",
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.25",
|
||||
"@actions/workflow-parser": "^0.3.25",
|
||||
"@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",
|
||||
"yaml": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 16.15"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -7,185 +7,6 @@ const schema = schemaImport as any;
|
||||
|
||||
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
|
||||
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
|
||||
const ALL_OUTPUT_PATH = "./src/context-providers/events/webhooks.all.json";
|
||||
const ALL_OBJECTS_PATH = "./src/context-providers/events/objects.all.json";
|
||||
const DROP_OUTPUT_PATH = "./src/context-providers/events/webhooks.drop.json";
|
||||
const DROP_OBJECTS_PATH = "./src/context-providers/events/objects.drop.json";
|
||||
const STRIP_OUTPUT_PATH = "./src/context-providers/events/webhooks.strip.json";
|
||||
const STRIP_OBJECTS_PATH = "./src/context-providers/events/objects.strip.json";
|
||||
|
||||
// Parse --all flag
|
||||
const generateAll = process.argv.includes("--all");
|
||||
|
||||
// Events to drop - not valid workflow triggers (GitHub App or API-only events)
|
||||
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||
const DROPPED_EVENTS = new Set([
|
||||
"branch_protection_configuration",
|
||||
"code_scanning_alert",
|
||||
"commit_comment",
|
||||
"custom_property",
|
||||
"custom_property_values",
|
||||
"dependabot_alert",
|
||||
"deploy_key",
|
||||
"github_app_authorization",
|
||||
"installation",
|
||||
"installation_repositories",
|
||||
"installation_target",
|
||||
"marketplace_purchase",
|
||||
"member",
|
||||
"membership",
|
||||
"merge_group",
|
||||
"meta",
|
||||
"org_block",
|
||||
"organization",
|
||||
"package",
|
||||
"personal_access_token_request",
|
||||
"ping",
|
||||
"repository",
|
||||
"repository_advisory",
|
||||
"repository_ruleset",
|
||||
"secret_scanning_alert",
|
||||
"secret_scanning_alert_location",
|
||||
"security_advisory",
|
||||
"security_and_analysis",
|
||||
"sponsorship",
|
||||
"star",
|
||||
"team",
|
||||
"team_add"
|
||||
]);
|
||||
|
||||
// Events to keep - valid workflow triggers
|
||||
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||
const KEPT_EVENTS = new Set([
|
||||
"branch_protection_rule",
|
||||
"check_run",
|
||||
"check_suite",
|
||||
"create",
|
||||
"delete",
|
||||
"deployment",
|
||||
"deployment_status",
|
||||
"discussion",
|
||||
"discussion_comment",
|
||||
"fork",
|
||||
"gollum",
|
||||
"issue_comment",
|
||||
"issues",
|
||||
"label",
|
||||
"milestone",
|
||||
"page_build",
|
||||
"project",
|
||||
"project_card",
|
||||
"project_column",
|
||||
"projects_v2",
|
||||
"projects_v2_item",
|
||||
"public",
|
||||
"pull_request",
|
||||
"pull_request_review",
|
||||
"pull_request_review_comment",
|
||||
"pull_request_review_thread",
|
||||
"push",
|
||||
"registry_package",
|
||||
"release",
|
||||
"repository_dispatch",
|
||||
"repository_import",
|
||||
"repository_vulnerability_alert",
|
||||
"status",
|
||||
"watch",
|
||||
"workflow_dispatch",
|
||||
"workflow_job",
|
||||
"workflow_run"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fields to strip from the JSON data.
|
||||
*
|
||||
* EVENT_ACTION_FIELDS: stripped from each event action object (top level only)
|
||||
* Example event action object before stripping:
|
||||
* {
|
||||
* "description": "This event is triggered when...", // <-- stripped
|
||||
* "summary": "A brief summary", // <-- stripped
|
||||
* "availability": ["repository"], // <-- stripped
|
||||
* "category": "issues", // <-- stripped
|
||||
* "action": "opened", // kept
|
||||
* "bodyParameters": [...] // kept
|
||||
* }
|
||||
*
|
||||
* BODY_PARAM_FIELDS: stripped from every bodyParameters object, recursively through childParamsGroups
|
||||
* Example bodyParameter object before stripping:
|
||||
* {
|
||||
* "type": "object", // <-- stripped
|
||||
* "name": "changes", // kept (used for property names)
|
||||
* "in": "body", // <-- stripped
|
||||
* "description": "The changes that were made.", // kept (used for hover docs)
|
||||
* "isRequired": true, // <-- stripped
|
||||
* "enum": ["a", "b"], // <-- stripped
|
||||
* "default": "a", // <-- stripped
|
||||
* "childParamsGroups": [ // kept (used for nested properties)
|
||||
* {
|
||||
* "type": "string", // <-- stripped (recursive)
|
||||
* "name": "from", // kept
|
||||
* "isRequired": true // <-- stripped (recursive)
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category"];
|
||||
const BODY_PARAM_FIELDS = ["type", "in", "isRequired", "enum", "default"];
|
||||
|
||||
/**
|
||||
* Strip fields from a bodyParameter object and recursively from childParamsGroups.
|
||||
*/
|
||||
function stripBodyParam(param: any): any {
|
||||
if (typeof param !== "object" || param === null) {
|
||||
return param;
|
||||
}
|
||||
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(param)) {
|
||||
if (BODY_PARAM_FIELDS.includes(key)) {
|
||||
continue; // Strip this field
|
||||
}
|
||||
if (key === "childParamsGroups" && Array.isArray(value)) {
|
||||
result[key] = value.map(stripBodyParam);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unused fields from event action data.
|
||||
*/
|
||||
function stripEventActionFields(action: any): any {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(action)) {
|
||||
if (EVENT_ACTION_FIELDS.includes(key)) {
|
||||
continue; // Strip this field
|
||||
}
|
||||
if (key === "bodyParameters" && Array.isArray(value)) {
|
||||
result[key] = value.map((p: any) => (typeof p === "number" ? p : stripBodyParam(p)));
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unused fields from all webhooks.
|
||||
* Structure: { eventName: { actionName: { ...fields } } }
|
||||
*/
|
||||
function stripFields(webhooks: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
|
||||
const result: Record<string, Record<string, any>> = {};
|
||||
for (const [eventName, actions] of Object.entries(webhooks)) {
|
||||
result[eventName] = {};
|
||||
for (const [actionName, actionData] of Object.entries(actions)) {
|
||||
result[eventName][actionName] = stripEventActionFields(actionData);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
|
||||
if (!rawWebhooks) {
|
||||
@@ -199,51 +20,11 @@ for (const webhook of Object.values(rawWebhooks)) {
|
||||
|
||||
await Promise.all(webhooks.map(webhook => webhook.process()));
|
||||
|
||||
// Check for unknown events (not in DROPPED_EVENTS or KEPT_EVENTS)
|
||||
const unknownEvents: string[] = [];
|
||||
for (const webhook of webhooks) {
|
||||
if (!DROPPED_EVENTS.has(webhook.category) && !KEPT_EVENTS.has(webhook.category)) {
|
||||
if (!unknownEvents.includes(webhook.category)) {
|
||||
unknownEvents.push(webhook.category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownEvents.length > 0) {
|
||||
console.error("");
|
||||
console.error("══════════════════════════════════════════════════════════════════");
|
||||
console.error("ERROR: New webhook event(s) detected!");
|
||||
console.error("══════════════════════════════════════════════════════════════════");
|
||||
console.error("");
|
||||
console.error("The following events are not categorized:");
|
||||
for (const event of unknownEvents.sort()) {
|
||||
console.error(` - ${event}`);
|
||||
}
|
||||
console.error("");
|
||||
console.error("Action required:");
|
||||
console.error(" 1. Check if the event is a valid workflow trigger:");
|
||||
console.error(
|
||||
" https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows"
|
||||
);
|
||||
console.error("");
|
||||
console.error(" 2. Add the event to DROPPED_EVENTS or KEPT_EVENTS in:");
|
||||
console.error(" languageservice/script/webhooks/index.ts");
|
||||
console.error("");
|
||||
console.error(" 3. See docs/json-data-files.md for more details.");
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// The category is the name of the webhook
|
||||
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
|
||||
for (const webhook of webhooks) {
|
||||
if (!webhook.action) webhook.action = "default";
|
||||
|
||||
// Drop unused events
|
||||
if (DROPPED_EVENTS.has(webhook.category)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (categorizedWebhooks[webhook.category]) {
|
||||
categorizedWebhooks[webhook.category][webhook.action] = webhook;
|
||||
} else {
|
||||
@@ -252,59 +33,7 @@ for (const webhook of webhooks) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strip fields before deduplication
|
||||
const strippedWebhooks = stripFields(categorizedWebhooks);
|
||||
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
|
||||
|
||||
// Deduplicate after dropping and stripping
|
||||
const objectsArray = deduplicateWebhooks(strippedWebhooks);
|
||||
|
||||
// Write optimized output
|
||||
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, null, 2));
|
||||
|
||||
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(strippedWebhooks).length} events)`);
|
||||
console.log(`Wrote ${OBJECTS_PATH} (${objectsArray.length} objects)`);
|
||||
|
||||
// Optionally generate intermediate versions for size comparison
|
||||
if (generateAll) {
|
||||
// Helper to deep clone
|
||||
function clone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
// Build full webhooks (no drop, no strip) from fresh data
|
||||
const fullWebhooks: Record<string, Record<string, any>> = {};
|
||||
for (const webhook of webhooks) {
|
||||
const w = clone(webhook);
|
||||
if (!w.action) w.action = "default";
|
||||
fullWebhooks[w.category] ||= {};
|
||||
fullWebhooks[w.category][w.action] = w;
|
||||
}
|
||||
|
||||
// Generate all version (no drop, no strip)
|
||||
const allWebhooks = clone(fullWebhooks);
|
||||
const allObjects = deduplicateWebhooks(allWebhooks);
|
||||
await fs.writeFile(ALL_OUTPUT_PATH, JSON.stringify(allWebhooks, null, 2));
|
||||
await fs.writeFile(ALL_OBJECTS_PATH, JSON.stringify(allObjects, null, 2));
|
||||
console.log(`Wrote ${ALL_OUTPUT_PATH} (${Object.keys(allWebhooks).length} events)`);
|
||||
console.log(`Wrote ${ALL_OBJECTS_PATH} (${allObjects.length} objects)`);
|
||||
|
||||
// Generate drop-only version (drop events, no strip)
|
||||
const dropWebhooks = clone(fullWebhooks);
|
||||
for (const event of DROPPED_EVENTS) {
|
||||
delete dropWebhooks[event];
|
||||
}
|
||||
const dropObjects = deduplicateWebhooks(dropWebhooks);
|
||||
await fs.writeFile(DROP_OUTPUT_PATH, JSON.stringify(dropWebhooks, null, 2));
|
||||
await fs.writeFile(DROP_OBJECTS_PATH, JSON.stringify(dropObjects, null, 2));
|
||||
console.log(`Wrote ${DROP_OUTPUT_PATH} (${Object.keys(dropWebhooks).length} events)`);
|
||||
console.log(`Wrote ${DROP_OBJECTS_PATH} (${dropObjects.length} objects)`);
|
||||
|
||||
// Generate strip-only version (strip fields, no drop)
|
||||
const stripWebhooks = stripFields(clone(fullWebhooks));
|
||||
const stripObjects = deduplicateWebhooks(stripWebhooks);
|
||||
await fs.writeFile(STRIP_OUTPUT_PATH, JSON.stringify(stripWebhooks, null, 2));
|
||||
await fs.writeFile(STRIP_OBJECTS_PATH, JSON.stringify(stripObjects, null, 2));
|
||||
console.log(`Wrote ${STRIP_OUTPUT_PATH} (${Object.keys(stripWebhooks).length} events)`);
|
||||
console.log(`Wrote ${STRIP_OBJECTS_PATH} (${stripObjects.length} objects)`);
|
||||
}
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
|
||||
|
||||
@@ -299,16 +299,7 @@ jobs:
|
||||
"on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo";
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"arch",
|
||||
"debug",
|
||||
"environment",
|
||||
"name",
|
||||
"os",
|
||||
"temp",
|
||||
"tool_cache",
|
||||
"workspace"
|
||||
]);
|
||||
expect(result.map(x => x.label)).toEqual(["arch", "name", "os", "temp", "tool_cache"]);
|
||||
});
|
||||
|
||||
describe("job if", () => {
|
||||
@@ -870,7 +861,7 @@ jobs:
|
||||
});
|
||||
|
||||
describe("strategy context", () => {
|
||||
it("strategy is suggested even when no strategy defined", async () => {
|
||||
it("strategy is not suggested when outside of a matrix job", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -884,7 +875,7 @@ jobs:
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).toContain("strategy");
|
||||
expect(result.map(x => x.label)).not.toContain("strategy");
|
||||
});
|
||||
|
||||
it("strategy is suggested within a matrix job", async () => {
|
||||
@@ -931,7 +922,7 @@ jobs:
|
||||
});
|
||||
|
||||
describe("matrix context", () => {
|
||||
it("matrix is suggested even when no strategy defined", async () => {
|
||||
it("matrix is not suggested when outside of a matrix job", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -945,7 +936,7 @@ jobs:
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).toContain("matrix");
|
||||
expect(result.map(x => x.label)).not.toContain("strategy");
|
||||
});
|
||||
|
||||
it("matrix is suggested within a matrix job", async () => {
|
||||
@@ -1132,12 +1123,10 @@ jobs:
|
||||
"github",
|
||||
"inputs",
|
||||
"job",
|
||||
"matrix",
|
||||
"needs",
|
||||
"runner",
|
||||
"secrets",
|
||||
"steps",
|
||||
"strategy",
|
||||
"vars",
|
||||
"contains",
|
||||
"endsWith",
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import {complete} from "./complete";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("Issue #81 - multi-line if expression completion", () => {
|
||||
it("should complete in block scalar if with | (exact position)", async () => {
|
||||
// Exact reproduction from issue - cursor after "github." in block scalar
|
||||
const input = `on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
github.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
// Line 5 (0-indexed) = " github.", character 13 = after the dot
|
||||
const pos = {line: 5, character: 13};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
expect(result.map(x => x.label)).toContain("actor");
|
||||
});
|
||||
|
||||
it("should complete in block scalar if with > (exact position)", async () => {
|
||||
const input = `on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: >
|
||||
github.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
const pos = {line: 5, character: 13};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete in block scalar with multiple lines", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
// Skip 1 to skip the `|` block scalar indicator (same character as cursor marker)
|
||||
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete step if in block scalar", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
if: |
|
||||
github.
|
||||
`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
// Line 7 = " github.", character 15 = after the dot (8 spaces + 7 chars)
|
||||
const pos = {line: 7, character: 15};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete in block scalar with ${{ expression markers", async () => {
|
||||
// This case works because transform() skips lines with ${{
|
||||
// Note: Using explicit position because | appears in multiple places (block scalar, ||, cursor)
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
\${{
|
||||
github.ref == 'refs/heads/main' ||
|
||||
github.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
// Line 6 = " github." = 8 spaces + 7 chars = 15 chars, cursor after dot is at char 15
|
||||
const pos = {line: 6, character: 15};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("ref");
|
||||
expect(result.map(x => x.label)).toContain("ref_name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases for getOffsetInContent", () => {
|
||||
it("should complete in single-line if (not block scalar)", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete on third content line of block scalar", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete when block scalar has empty first line", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
|
||||
github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
||||
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
@@ -20,6 +19,7 @@ import {isPotentiallyExpression} from "./utils/expression-detection";
|
||||
import {findToken} from "./utils/find-token";
|
||||
import {guessIndentation} from "./utils/indentation-guesser";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {getRelCharOffset} from "./utils/rel-char-pos";
|
||||
import {isPlaceholder, transform} from "./utils/transform";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||
import {Value, ValueProviderConfig} from "./value-providers/config";
|
||||
@@ -238,12 +238,12 @@ function getExpressionCompletionItems(
|
||||
currentInput = stringToken.source || stringToken.value;
|
||||
}
|
||||
|
||||
const cursorOffset = getOffsetInContent(token.range, currentInput, pos);
|
||||
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
||||
const relCharOffset = getRelCharOffset(token.range, currentInput, pos);
|
||||
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
|
||||
|
||||
try {
|
||||
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
|
||||
mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
||||
mapExpressionCompletionItem(item, currentInput[relCharOffset])
|
||||
);
|
||||
} catch (e) {
|
||||
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
||||
@@ -274,50 +274,3 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo
|
||||
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a document position to an offset within the token's content string.
|
||||
*/
|
||||
function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: Position): number {
|
||||
const range = mapRange(tokenRange);
|
||||
|
||||
if (range.start.line === range.end.line) {
|
||||
// Single-line example:
|
||||
// if: github.ref == 'main'
|
||||
// ^8 ^15 (cursor)
|
||||
// currentInput = "github.ref == 'main'"
|
||||
// offset = 15 - 8 = 7
|
||||
return pos.character - range.start.character;
|
||||
}
|
||||
|
||||
// Multi-line example:
|
||||
// if: | <- line 3 (range.start.line)
|
||||
// first line <- line 4, content line 0
|
||||
// second line <- line 5, content line 1
|
||||
// github. <- line 6, content line 2, cursor at index 11
|
||||
// ^11 (cursor)
|
||||
//
|
||||
// currentInput = " first line\n second line\n github."
|
||||
// ^0 ^15 ^32 ^43
|
||||
|
||||
// Line index within content.
|
||||
// From the example:
|
||||
// lineIndexWithinContent = pos.line - range.start.line - 1
|
||||
// = 6 - 3 - 1 = 2
|
||||
const lineIndexWithinContent = pos.line - range.start.line - 1;
|
||||
|
||||
// Length of content before current line.
|
||||
// From the example:
|
||||
// lengthOfContentBeforeCurrentLine => 14 + 1 = 15 (after first iteration)
|
||||
// => 31 + 1 = 32 (after second iteration)
|
||||
let lengthOfContentBeforeCurrentLine = 0;
|
||||
for (let i = 0; i < lineIndexWithinContent; i++) {
|
||||
lengthOfContentBeforeCurrentLine = currentInput.indexOf("\n", lengthOfContentBeforeCurrentLine) + 1;
|
||||
}
|
||||
|
||||
// Final offset within content.
|
||||
// From the example:
|
||||
// finalOffset = lengthOfContentBeforeCurrentLine + pos.character
|
||||
// = 32 + 11 = 43
|
||||
return lengthOfContentBeforeCurrentLine + pos.character;
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import {DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {getContext, Mode} from "./default";
|
||||
|
||||
describe("getContext", () => {
|
||||
const emptyWorkflowContext: WorkflowContext = {
|
||||
uri: "test.yaml",
|
||||
template: undefined
|
||||
};
|
||||
|
||||
describe("when no contextProviderConfig is provided", () => {
|
||||
it("should mark secrets context as incomplete", async () => {
|
||||
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||
expect(secretsContext).toBeDefined();
|
||||
expect(secretsContext.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should mark vars context as incomplete", async () => {
|
||||
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||
expect(varsContext).toBeDefined();
|
||||
expect(varsContext.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should not mark other contexts as incomplete", async () => {
|
||||
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const envContext = result.get("env") as DescriptionDictionary;
|
||||
const githubContext = result.get("github") as DescriptionDictionary;
|
||||
|
||||
// These contexts are derived from the workflow file, so they can be complete
|
||||
expect(envContext).toBeDefined();
|
||||
expect(envContext.complete).toBe(true);
|
||||
expect(githubContext).toBeDefined();
|
||||
expect(githubContext.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when contextProviderConfig returns a value", () => {
|
||||
it("should use the provided context for secrets", async () => {
|
||||
const providedContext = new DescriptionDictionary();
|
||||
providedContext.complete = true; // Provider fetched from API, so it's complete
|
||||
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(providedContext)
|
||||
};
|
||||
|
||||
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets");
|
||||
expect(secretsContext).toBe(providedContext);
|
||||
expect((secretsContext as DescriptionDictionary).complete).toBe(true);
|
||||
});
|
||||
|
||||
it("should use the provided context for vars", async () => {
|
||||
const providedContext = new DescriptionDictionary();
|
||||
providedContext.complete = true;
|
||||
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(providedContext)
|
||||
};
|
||||
|
||||
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars");
|
||||
expect(varsContext).toBe(providedContext);
|
||||
expect((varsContext as DescriptionDictionary).complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when contextProviderConfig returns undefined", () => {
|
||||
it("should mark secrets as incomplete", async () => {
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(undefined)
|
||||
};
|
||||
|
||||
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||
expect(secretsContext.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should mark vars as incomplete", async () => {
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(undefined)
|
||||
};
|
||||
|
||||
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||
expect(varsContext.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,24 +32,15 @@ export async function getContext(
|
||||
): Promise<DescriptionDictionary> {
|
||||
const context = new DescriptionDictionary();
|
||||
|
||||
// All context names are valid - strategy and matrix are always available
|
||||
// (with default values when no strategy block is defined)
|
||||
for (const contextName of names) {
|
||||
const filteredNames = filterContextNames(names, workflowContext);
|
||||
for (const contextName of filteredNames) {
|
||||
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
|
||||
if (value.kind === Kind.Null) {
|
||||
context.add(contextName, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
|
||||
if (remoteValue) {
|
||||
value = remoteValue;
|
||||
} else if (contextName === "secrets" || contextName === "vars") {
|
||||
// Without a context provider to fetch remote secrets/vars, we can't know
|
||||
// what values exist, so mark the context as incomplete to avoid false
|
||||
// "Context access might be invalid" warnings
|
||||
value.complete = false;
|
||||
}
|
||||
value = (await config?.getContext(contextName, value, workflowContext, mode)) || value;
|
||||
|
||||
context.add(contextName, value, getDescription(RootContext, contextName));
|
||||
}
|
||||
@@ -83,14 +74,11 @@ function getDefaultContext(name: string, workflowContext: WorkflowContext, mode:
|
||||
|
||||
case "runner":
|
||||
return objectToDictionary({
|
||||
arch: "X64",
|
||||
debug: "1",
|
||||
environment: "github-hosted",
|
||||
name: "GitHub Actions 2",
|
||||
os: "Linux",
|
||||
temp: "/home/runner/work/_temp",
|
||||
arch: "X64",
|
||||
name: "GitHub Actions 2",
|
||||
tool_cache: "/opt/hostedtoolcache",
|
||||
workspace: "/home/runner/work/repo"
|
||||
temp: "/home/runner/work/_temp"
|
||||
});
|
||||
|
||||
case "secrets":
|
||||
@@ -115,3 +103,18 @@ function objectToDictionary(object: {[key: string]: string}): DescriptionDiction
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
|
||||
return contextNames.filter(name => {
|
||||
switch (name) {
|
||||
case "matrix":
|
||||
case "strategy":
|
||||
return hasStrategy(workflowContext);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function hasStrategy(workflowContext: WorkflowContext): boolean {
|
||||
return workflowContext.job?.strategy !== undefined || workflowContext.reusableWorkflowJob?.strategy !== undefined;
|
||||
}
|
||||
|
||||
@@ -239,13 +239,7 @@
|
||||
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software).\""
|
||||
},
|
||||
"debug": {
|
||||
"description": "This is set only if [`ACTIONS_STEP_DEBUG`](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `\"1\"`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
|
||||
},
|
||||
"environment": {
|
||||
"description": "The environment of the runner executing the job. Possible values are `github-hosted` for GitHub-hosted runners, or `self-hosted` for self-hosted runners."
|
||||
},
|
||||
"workspace": {
|
||||
"description": "The runner-specific working directory path for the job."
|
||||
"description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `1`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
|
||||
}
|
||||
},
|
||||
"strategy": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import descriptions from "./descriptions.min.json";
|
||||
import descriptions from "./descriptions.json";
|
||||
|
||||
export const RootContext = "root";
|
||||
const FunctionContext = "functions";
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import {DescriptionDictionary} from "@actions/expressions";
|
||||
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads";
|
||||
|
||||
describe("eventPayloads", () => {
|
||||
describe("getSupportedEventTypes", () => {
|
||||
it("returns action types for push event", () => {
|
||||
const types = getSupportedEventTypes("push");
|
||||
expect(types).toContain("default");
|
||||
});
|
||||
|
||||
it("returns action types for issues event", () => {
|
||||
const types = getSupportedEventTypes("issues");
|
||||
expect(types.length).toBeGreaterThan(1);
|
||||
expect(types).toContain("opened");
|
||||
expect(types).toContain("closed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventPayload", () => {
|
||||
it("returns payload for push event", () => {
|
||||
const payload = getEventPayload("push", "default");
|
||||
expect(payload).toBeDefined();
|
||||
|
||||
// Verify common fields exist
|
||||
expect(payload?.get("ref")).toBeDefined();
|
||||
expect(payload?.get("repository")).toBeDefined();
|
||||
expect(payload?.get("sender")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns payload for issues event", () => {
|
||||
const payload = getEventPayload("issues", "opened");
|
||||
expect(payload).toBeDefined();
|
||||
|
||||
expect(payload?.get("action")).toBeDefined();
|
||||
expect(payload?.get("issue")).toBeDefined();
|
||||
expect(payload?.get("repository")).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves descriptions for hover documentation", () => {
|
||||
// This test ensures bodyParameters[].description is not stripped
|
||||
// during JSON optimization. The description field is used for hover
|
||||
// documentation in the workflow editor.
|
||||
const payload = getEventPayload("push", "default");
|
||||
expect(payload).toBeDefined();
|
||||
|
||||
// Get the description for a well-known field
|
||||
// repository should have a description like "A repository on GitHub"
|
||||
const repoDescription = payload?.getDescription("repository");
|
||||
expect(repoDescription).toBeDefined();
|
||||
expect(repoDescription?.length).toBeGreaterThan(0);
|
||||
|
||||
// sender should have a description
|
||||
const senderDescription = payload?.getDescription("sender");
|
||||
expect(senderDescription).toBeDefined();
|
||||
expect(senderDescription?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves childParamsGroups for nested property access", () => {
|
||||
// This test ensures bodyParameters[].childParamsGroups is not stripped
|
||||
// during JSON optimization. childParamsGroups defines nested properties
|
||||
// used for autocompletion like github.event.repository.owner.login
|
||||
const payload = getEventPayload("push", "default");
|
||||
expect(payload).toBeDefined();
|
||||
|
||||
// repository has nested properties like owner, license, etc.
|
||||
const repository = payload?.get("repository") as DescriptionDictionary | undefined;
|
||||
expect(repository).toBeDefined();
|
||||
|
||||
// repository.owner should exist (nested via childParamsGroups)
|
||||
const owner = repository?.get("owner") as DescriptionDictionary | undefined;
|
||||
expect(owner).toBeDefined();
|
||||
|
||||
// repository.owner.login should exist (deeply nested)
|
||||
const login = owner?.get("login");
|
||||
expect(login).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves name fields for property identification", () => {
|
||||
// This test ensures bodyParameters[].name is not stripped
|
||||
// during JSON optimization. The name field identifies each property.
|
||||
const payload = getEventPayload("issues", "opened");
|
||||
expect(payload).toBeDefined();
|
||||
|
||||
// Verify well-known property names exist
|
||||
expect(payload?.get("action")).toBeDefined();
|
||||
expect(payload?.get("issue")).toBeDefined();
|
||||
expect(payload?.get("repository")).toBeDefined();
|
||||
expect(payload?.get("sender")).toBeDefined();
|
||||
|
||||
// Verify nested property names work
|
||||
const issue = payload?.get("issue") as DescriptionDictionary | undefined;
|
||||
expect(issue?.get("title")).toBeDefined();
|
||||
expect(issue?.get("number")).toBeDefined();
|
||||
expect(issue?.get("user")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined for unknown event", () => {
|
||||
const payload = getEventPayload("not_a_real_event", "default");
|
||||
expect(payload).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
|
||||
import webhookObjects from "./objects.min.json";
|
||||
import webhooks from "./webhooks.min.json";
|
||||
import webhookObjects from "./objects.json";
|
||||
import webhooks from "./webhooks.json";
|
||||
|
||||
import schedule from "./schedule.min.json";
|
||||
import workflow_call from "./workflow_call.min.json";
|
||||
import schedule from "./schedule.json";
|
||||
import workflow_call from "./workflow_call.json";
|
||||
|
||||
const customEventPayloads: {[name: string]: unknown} = {
|
||||
schedule,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job).toBeUndefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new data.Null());
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
});
|
||||
|
||||
it("strategy not defined", () => {
|
||||
@@ -73,7 +73,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job!.strategy).toBeUndefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new data.Null());
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
});
|
||||
|
||||
it("strategy is not a mapping token", () => {
|
||||
@@ -81,7 +81,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job!.strategy).toBeDefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new data.Null());
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
});
|
||||
|
||||
it("matrix is not defined", () => {
|
||||
|
||||
@@ -10,8 +10,7 @@ export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
|
||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||
if (!strategy || !isMapping(strategy)) {
|
||||
// No strategy defined - matrix is null at runtime (not empty object)
|
||||
return new data.Null();
|
||||
return new DescriptionDictionary();
|
||||
}
|
||||
|
||||
const matrix = strategy.find("matrix");
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {getStepsContext} from "./steps";
|
||||
|
||||
function createWorkflowContext(stepIds: string[], currentStepId?: string): WorkflowContext {
|
||||
return {
|
||||
job: {
|
||||
steps: stepIds.map(id => ({id}))
|
||||
},
|
||||
step: currentStepId ? {id: currentStepId} : undefined
|
||||
} as WorkflowContext;
|
||||
}
|
||||
|
||||
describe("steps context", () => {
|
||||
it("returns empty dictionary when no job", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
const context = getStepsContext(workflowContext);
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty dictionary when no steps", () => {
|
||||
const workflowContext = {job: {}} as WorkflowContext;
|
||||
const context = getStepsContext(workflowContext);
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("includes steps with user-defined ids", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "step-b"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("step-b")).toBeDefined();
|
||||
});
|
||||
|
||||
it("excludes generated step ids (starting with __)", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "__generated"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("__generated")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("excludes current step and later steps", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "step-b", "step-c"], "step-b");
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("step-b")).toBeUndefined();
|
||||
expect(context.get("step-c")).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("step outputs", () => {
|
||||
it("outputs is a dictionary, not null", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
const stepContext = context.get("step-a");
|
||||
expect(stepContext).toBeDefined();
|
||||
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
||||
|
||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||
expect(outputs).toBeDefined();
|
||||
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
||||
});
|
||||
|
||||
it("outputs is marked incomplete to allow dynamic outputs", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
const stepContext = context.get("step-a") as DescriptionDictionary;
|
||||
const outputs = stepContext.get("outputs") as DescriptionDictionary;
|
||||
|
||||
// Outputs should be incomplete since we can't know what outputs a step will produce
|
||||
expect(outputs.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,10 +31,7 @@ function stepContext(): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context
|
||||
const d = new DescriptionDictionary();
|
||||
|
||||
// Step outputs are dynamic - actions can generate outputs based on their inputs
|
||||
const outputs = new DescriptionDictionary();
|
||||
outputs.complete = false;
|
||||
d.add("outputs", outputs, getDescription("steps", "outputs"));
|
||||
d.add("outputs", new data.Null(), getDescription("steps", "outputs"));
|
||||
|
||||
// Can be "success", "failure", "cancelled", or "skipped"
|
||||
d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import {data} from "@actions/expressions";
|
||||
import {Job} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {BooleanToken} from "@actions/workflow-parser/templates/tokens/boolean-token";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {NumberToken} from "@actions/workflow-parser/templates/tokens/number-token";
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {getStrategyContext} from "./strategy";
|
||||
|
||||
function stringToToken(value: string) {
|
||||
return new StringToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function boolToToken(value: boolean) {
|
||||
return new BooleanToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function numberToToken(value: number) {
|
||||
return new NumberToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function contextFromStrategy(strategy?: TemplateToken) {
|
||||
return {
|
||||
job: {
|
||||
strategy: strategy
|
||||
}
|
||||
} as WorkflowContext;
|
||||
}
|
||||
|
||||
describe("strategy context", () => {
|
||||
describe("no strategy defined", () => {
|
||||
it("returns defaults when job is undefined", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("returns defaults when strategy is undefined", () => {
|
||||
const job = {} as Job;
|
||||
const workflowContext = {job} as WorkflowContext;
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("returns defaults when strategy is not a mapping", () => {
|
||||
const workflowContext = contextFromStrategy(stringToToken("hello"));
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("strategy defined with partial properties", () => {
|
||||
it("uses specified fail-fast, defaults for others", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("fail-fast"), boolToToken(false));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("uses specified max-parallel, defaults for others", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("max-parallel"), numberToToken(5));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(5));
|
||||
});
|
||||
|
||||
it("only has matrix defined, all strategy properties use defaults", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
const matrix = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("matrix"), matrix);
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("strategy with all properties defined", () => {
|
||||
it("uses all specified values", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("fail-fast"), boolToToken(false));
|
||||
strategy.add(stringToToken("max-parallel"), numberToToken(3));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
|
||||
// job-index and job-total are runtime values, not specified in YAML
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(3));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,24 +3,15 @@ import {isMapping, isScalar, isString} from "@actions/workflow-parser";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {scalarToData} from "../utils/scalar-to-data";
|
||||
|
||||
// Default strategy values when no strategy block is defined
|
||||
const DEFAULT_STRATEGY = {
|
||||
"fail-fast": new data.BooleanData(true),
|
||||
"job-index": new data.NumberData(0),
|
||||
"job-total": new data.NumberData(1),
|
||||
"max-parallel": new data.NumberData(1)
|
||||
};
|
||||
|
||||
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
|
||||
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
|
||||
|
||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||
if (!strategy || !isMapping(strategy)) {
|
||||
// No strategy defined - return defaults that match runtime behavior
|
||||
return new DescriptionDictionary(
|
||||
...keys.map(key => {
|
||||
return {key, value: DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]};
|
||||
return {key, value: new data.Null()};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -40,8 +31,7 @@ export function getStrategyContext(workflowContext: WorkflowContext): Descriptio
|
||||
|
||||
for (const key of keys) {
|
||||
if (!strategyContext.get(key)) {
|
||||
// Use default value for missing properties
|
||||
strategyContext.add(key, DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]);
|
||||
strategyContext.add(key, new data.Null());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
||||
import {Position} from "vscode-languageserver-textdocument";
|
||||
import {mapRange} from "./range";
|
||||
|
||||
export function getRelCharOffset(tokenRange: TokenRange, currentInput: string, pos: Position): number {
|
||||
const range = mapRange(tokenRange);
|
||||
if (range.start.line !== range.end.line) {
|
||||
const lines = currentInput.split("\n");
|
||||
const lineDiff = pos.line - range.start.line - 1;
|
||||
const linesBeforeCusor = lines.slice(0, lineDiff);
|
||||
return linesBeforeCusor.join("\n").length + pos.character + 1;
|
||||
} else {
|
||||
return pos.character - range.start.character;
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {validate} from "./validate";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("validate concurrency deadlock", () => {
|
||||
describe("should error on matching concurrency groups", () => {
|
||||
it("simple string match", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
|
||||
// Workflow-level warning
|
||||
expect(concurrencyErrors[0]).toMatchObject({
|
||||
message: "Concurrency group 'test' is also used by job 'job1'. This will cause a deadlock.",
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
|
||||
// Job-level warning
|
||||
expect(concurrencyErrors[1]).toMatchObject({
|
||||
message: "Concurrency group 'test' is also defined at the workflow level. This will cause a deadlock.",
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
});
|
||||
|
||||
it("workflow mapping form, job string form", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: my-group
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: my-group
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
expect(concurrencyErrors[0].message).toContain("my-group");
|
||||
expect(concurrencyErrors[0].message).toContain("deploy");
|
||||
});
|
||||
|
||||
it("workflow string form, job mapping form", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: deploy-group
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: deploy-group
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
expect(concurrencyErrors[0].message).toContain("deploy-group");
|
||||
});
|
||||
|
||||
it("both mapping forms", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: shared
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: shared
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("multiple jobs with matching concurrency", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: shared
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: shared
|
||||
steps:
|
||||
- run: echo hi
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: shared
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
// Should have 2 warnings per job (workflow + job) = 4 total, but workflow is only warned once per match
|
||||
// Actually: 1 workflow warning per matching job + 1 job warning per matching job = 4 total
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("should not warn", () => {
|
||||
it("different concurrency groups", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: workflow-group
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: job-group
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("workflow concurrency is an expression", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: \${{ github.ref }}
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("job concurrency is an expression", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: \${{ github.ref }}
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no workflow-level concurrency", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no job-level concurrency", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("case sensitive - different case is different group", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: Test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("workflow concurrency group in mapping is an expression", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: \${{ github.ref }}
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -681,8 +681,7 @@ jobs:
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
// Strategy context is always available with default values
|
||||
expect(result).toEqual([]);
|
||||
expect(result).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("invalid strategy property", async () => {
|
||||
@@ -997,8 +996,22 @@ jobs:
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
// Matrix is null when no strategy is defined, accessing properties on null is valid
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Context access might be invalid: matrix",
|
||||
range: {
|
||||
end: {
|
||||
character: 36,
|
||||
line: 8
|
||||
},
|
||||
start: {
|
||||
character: 18,
|
||||
line: 8
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Warning
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("basic matrix", async () => {
|
||||
@@ -1596,48 +1609,6 @@ jobs:
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows runner.environment context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: runner.environment == 'github-hosted'
|
||||
run: echo hello`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows runner.debug context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: runner.debug == '1'
|
||||
run: echo hello`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows runner.workspace context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: runner.workspace != ''
|
||||
run: echo hello`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows env context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* Test validation behavior when no context providers are configured.
|
||||
*
|
||||
* When contextProviderConfig is not provided (or returns incomplete data),
|
||||
* we should skip validation for secrets/vars rather than showing false
|
||||
* positive "Context access might be invalid" warnings.
|
||||
*
|
||||
* This is important for offline/disconnected scenarios where API calls
|
||||
* to fetch secrets/vars are not possible.
|
||||
*/
|
||||
|
||||
import {validate} from "./validate";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("validation without context providers", () => {
|
||||
describe("secrets context", () => {
|
||||
it("should not warn on secrets.GITHUB_TOKEN", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "test"
|
||||
env:
|
||||
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on custom secrets when no provider configured", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "test"
|
||||
env:
|
||||
API_KEY: \${{ secrets.MY_API_KEY }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on secrets with environment", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- run: echo "test"
|
||||
env:
|
||||
API_KEY: \${{ secrets.API_KEY }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vars context", () => {
|
||||
it("should not warn on vars when no provider configured", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "\${{ vars.ENVIRONMENT }}"
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on vars with environment", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- run: echo "\${{ vars.API_URL }}"
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on vars with fallback pattern", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "\${{ vars.OPTIONAL_VAR || 'default-value' }}"
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined secrets and vars", () => {
|
||||
it("should not warn on workflow using both secrets and vars", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- run: |
|
||||
echo "Deploying to \${{ vars.API_URL }}"
|
||||
echo "Using region \${{ vars.AWS_REGION }}"
|
||||
env:
|
||||
API_KEY: \${{ secrets.API_KEY }}
|
||||
AWS_SECRET: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -385,31 +385,4 @@ jobs:
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow_dispatch", () => {
|
||||
it("allows empty string in choice options", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
`on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
plugin-name:
|
||||
description: Specific plugin to build
|
||||
type: choice
|
||||
options:
|
||||
- ''
|
||||
- foo
|
||||
- bar
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`
|
||||
)
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Lexer, Parser, data} from "@actions/expressions";
|
||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||
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";
|
||||
@@ -209,9 +209,6 @@ async function additionalValidations(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate concurrency deadlock between workflow and job levels
|
||||
validateConcurrencyDeadlock(diagnostics, template);
|
||||
}
|
||||
|
||||
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
|
||||
@@ -715,71 +712,3 @@ async function validateExpression(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that workflow-level and job-level concurrency groups don't match,
|
||||
* which would cause a deadlock at runtime.
|
||||
*/
|
||||
function validateConcurrencyDeadlock(diagnostics: Diagnostic[], template: WorkflowTemplate): void {
|
||||
const workflowGroup = getStaticConcurrencyGroup(template.concurrency);
|
||||
if (!workflowGroup) {
|
||||
return; // No workflow-level concurrency or it's an expression
|
||||
}
|
||||
|
||||
for (const job of template.jobs || []) {
|
||||
if (!job.concurrency) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const jobGroup = getStaticConcurrencyGroup(job.concurrency);
|
||||
if (!jobGroup) {
|
||||
continue; // Job concurrency is an expression
|
||||
}
|
||||
|
||||
if (workflowGroup.value === jobGroup.value) {
|
||||
// Error on workflow-level concurrency
|
||||
if (template.concurrency.range) {
|
||||
diagnostics.push({
|
||||
message: `Concurrency group '${workflowGroup.value}' is also used by job '${job.id.value}'. This will cause a deadlock.`,
|
||||
range: mapRange(template.concurrency.range),
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
}
|
||||
|
||||
// Error on job-level concurrency
|
||||
if (job.concurrency.range) {
|
||||
diagnostics.push({
|
||||
message: `Concurrency group '${jobGroup.value}' is also defined at the workflow level. This will cause a deadlock.`,
|
||||
range: mapRange(job.concurrency.range),
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the static concurrency group name from a concurrency token.
|
||||
* Returns undefined if the token is an expression or doesn't have a static group.
|
||||
*/
|
||||
function getStaticConcurrencyGroup(token: TemplateToken | undefined): StringToken | undefined {
|
||||
if (!token || token.isExpression) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Simple string form: concurrency: "test"
|
||||
if (isString(token)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// Mapping form: concurrency: { group: "test", cancel-in-progress: true }
|
||||
if (isMapping(token)) {
|
||||
for (const pair of token) {
|
||||
if (isString(pair.key) && pair.key.value === "group" && isString(pair.value) && !pair.value.isExpression) {
|
||||
return pair.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import {validate} from "./validate";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("YAML anchors and aliases", () => {
|
||||
it("should handle anchors and aliases in env", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env: &env
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env: *env
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle multiple aliases to the same anchor", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
env: &shared
|
||||
SHARED: true
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
job3:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle anchors in matrix strategy", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include: &matrix-include
|
||||
- os: ubuntu-latest
|
||||
node: 18
|
||||
- os: windows-latest
|
||||
node: 20
|
||||
steps:
|
||||
- run: exit 0
|
||||
test2:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include: *matrix-include
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle anchors in steps", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- &checkout
|
||||
uses: actions/checkout@v4
|
||||
- run: npm test
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- *checkout
|
||||
- run: npm run deploy
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle scalar anchors", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: &runner ubuntu-latest
|
||||
steps:
|
||||
- run: exit 0
|
||||
test:
|
||||
runs-on: *runner
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should work without anchors (control test)", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle circular aliases without hanging", async () => {
|
||||
// This is an invalid use case (alias referencing parent) but should not hang
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env: &myenv
|
||||
FOO: bar
|
||||
nested: *myenv
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
// Should complete without hanging - circular portion is silently ignored
|
||||
// which may cause downstream validation errors, but that's acceptable
|
||||
const result = await validate(doc);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle undefined alias references", async () => {
|
||||
// Reference to non-existent anchor - yaml library should report error
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env: *nonexistent
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.25"
|
||||
"version": "0.3.22"
|
||||
}
|
||||
Generated
+13
-13
@@ -135,7 +135,7 @@
|
||||
},
|
||||
"expressions": {
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.25",
|
||||
"version": "0.3.22",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -151,7 +151,7 @@
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 16.15"
|
||||
}
|
||||
},
|
||||
"expressions/node_modules/@eslint/eslintrc": {
|
||||
@@ -395,11 +395,11 @@
|
||||
},
|
||||
"languageserver": {
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.25",
|
||||
"version": "0.3.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.25",
|
||||
"@actions/workflow-parser": "^0.3.25",
|
||||
"@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",
|
||||
@@ -421,7 +421,7 @@
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 16.15"
|
||||
}
|
||||
},
|
||||
"languageserver/node_modules/@eslint/eslintrc": {
|
||||
@@ -921,11 +921,11 @@
|
||||
},
|
||||
"languageservice": {
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.25",
|
||||
"version": "0.3.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.25",
|
||||
"@actions/workflow-parser": "^0.3.25",
|
||||
"@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",
|
||||
@@ -947,7 +947,7 @@
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 16.15"
|
||||
}
|
||||
},
|
||||
"languageservice/node_modules/@eslint/eslintrc": {
|
||||
@@ -12834,10 +12834,10 @@
|
||||
},
|
||||
"workflow-parser": {
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.25",
|
||||
"version": "0.3.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.25",
|
||||
"@actions/expressions": "^0.3.22",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
@@ -12855,7 +12855,7 @@
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 16.15"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "actions-languageservices",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"./expressions",
|
||||
"./workflow-parser",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Minifies JSON files by removing whitespace.
|
||||
*
|
||||
* Usage: node script/minify-json.js <file1.json> <file2.json> ...
|
||||
*
|
||||
* For each input file, creates a corresponding .min.json file.
|
||||
* Example: src/data.json -> src/data.min.json
|
||||
*/
|
||||
|
||||
import {promises as fs} from "fs";
|
||||
import path from "path";
|
||||
|
||||
const files = process.argv.slice(2);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error("Usage: node script/minify-json.js <file1.json> <file2.json> ...");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await fs.readFile(file, "utf8");
|
||||
const data = JSON.parse(content);
|
||||
const minified = JSON.stringify(data);
|
||||
|
||||
// Replace .json with .min.json
|
||||
const ext = path.extname(file);
|
||||
const outputFile = file.slice(0, -ext.length) + ".min" + ext;
|
||||
|
||||
await fs.writeFile(outputFile, minified);
|
||||
|
||||
const originalSize = Buffer.byteLength(content, "utf8");
|
||||
const minifiedSize = Buffer.byteLength(minified, "utf8");
|
||||
const savings = ((1 - minifiedSize / originalSize) * 100).toFixed(1);
|
||||
|
||||
console.log(`${file} -> ${outputFile} (${savings}% smaller)`);
|
||||
} catch (err) {
|
||||
console.error(`Error processing ${file}:`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.25",
|
||||
"version": "0.3.22",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -38,22 +38,19 @@
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"minify-json": "node ../script/minify-json.js src/workflow-v1.0.json",
|
||||
"prebuild": "npm run minify-json",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"pretest": "npm run minify-json",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
"test-xlang": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathPattern xlang",
|
||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.25",
|
||||
"@actions/expressions": "^0.3.22",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 16.15"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import * as fs from "fs";
|
||||
|
||||
/**
|
||||
* This test ensures that activity types in workflow-v1.0.json stay in sync with
|
||||
* the webhooks.json file from the languageservice package.
|
||||
*
|
||||
* When this test fails, it means new activity types were added to webhooks.json
|
||||
* that need to be handled. See docs/json-data-files.md for detailed instructions.
|
||||
*
|
||||
* Quick reference for fixing failures:
|
||||
* 1. Check https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
|
||||
* Find the event and look at its "Activity types" table to see if the type is a valid workflow trigger.
|
||||
* 2. If the activity type IS a valid workflow trigger:
|
||||
* → Add it to the corresponding *-activity-type definition in workflow-v1.0.json
|
||||
* 3. If the activity type is webhook-only (not in workflow docs):
|
||||
* → Add it to the WEBHOOK_ONLY list below
|
||||
* 4. If there's a naming difference between webhook and schema:
|
||||
* → Add it to the NAME_MAPPINGS list below
|
||||
* 5. If the schema has a type not in webhooks.json:
|
||||
* → Add it to the SCHEMA_ONLY list below
|
||||
*/
|
||||
|
||||
describe("schema-sync", () => {
|
||||
// Activity types that exist in webhooks.json but are intentionally NOT
|
||||
// supported as workflow triggers. These will be ignored when checking
|
||||
// webhooks → schema direction.
|
||||
const WEBHOOK_ONLY: Record<string, string[]> = {
|
||||
// check_suite: requested and rerequested are webhook-only, not valid workflow triggers
|
||||
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#check_suite
|
||||
check_suite: ["requested", "rerequested"],
|
||||
|
||||
// registry_package: "default" is a webhook concept, not a workflow trigger type
|
||||
registry_package: ["default"]
|
||||
};
|
||||
|
||||
// Activity types that exist in workflow schema but are intentionally NOT
|
||||
// in webhooks.json (schema-only types). These will be ignored when checking
|
||||
// schema → webhooks direction.
|
||||
const SCHEMA_ONLY: Record<string, string[]> = {
|
||||
// registry_package: "updated" is a valid workflow trigger per GitHub docs
|
||||
// but doesn't exist in webhooks.json (webhooks only has "published" and "default")
|
||||
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#registry_package
|
||||
registry_package: ["updated"]
|
||||
};
|
||||
|
||||
// Known naming differences between webhooks.json and workflow-v1.0.json.
|
||||
// Key: event name, Value: { webhook: "webhookName", schema: "schemaName" }
|
||||
// These are treated as equivalent when comparing in both directions.
|
||||
const NAME_MAPPINGS: Record<string, Array<{webhook: string; schema: string}>> = {
|
||||
// project_column: webhooks.json uses "edited" but workflow triggers use "updated"
|
||||
// This is a known naming difference - they represent the same action
|
||||
project_column: [{webhook: "edited", schema: "updated"}]
|
||||
};
|
||||
|
||||
it("activity types in workflow-v1.0.json match webhooks.json", () => {
|
||||
// Load webhooks.json (relative path from the test runner CWD which is the package root)
|
||||
const webhooksPath = "../languageservice/src/context-providers/events/webhooks.json";
|
||||
const webhooks = JSON.parse(fs.readFileSync(webhooksPath, "utf-8")) as Record<string, Record<string, unknown>>;
|
||||
|
||||
// Load workflow-v1.0.json
|
||||
const schemaPath = "./src/workflow-v1.0.json";
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as {
|
||||
definitions: Record<string, {"allowed-values"?: string[]; description?: string}>;
|
||||
};
|
||||
|
||||
const mismatches: string[] = [];
|
||||
|
||||
// Build mapping helpers for each event
|
||||
const getWebhookToSchemaMapping = (eventName: string): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
for (const mapping of NAME_MAPPINGS[eventName] || []) {
|
||||
map.set(mapping.webhook, mapping.schema);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
const getSchemaToWebhookMapping = (eventName: string): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
for (const mapping of NAME_MAPPINGS[eventName] || []) {
|
||||
map.set(mapping.schema, mapping.webhook);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
// Check both directions for each event
|
||||
for (const [eventName, eventData] of Object.entries(webhooks)) {
|
||||
const webhookTypes = Object.keys(eventData);
|
||||
if (webhookTypes.length === 0) continue;
|
||||
|
||||
const schemaTypeName = `${eventName.replace(/_/g, "-")}-activity-type`;
|
||||
const schemaDef = schema.definitions[schemaTypeName];
|
||||
|
||||
// If there's no activity type definition in the schema, this event
|
||||
// doesn't support activity types in workflows (e.g., push, pull)
|
||||
if (!schemaDef || !schemaDef["allowed-values"]) continue;
|
||||
|
||||
const schemaTypes = new Set(schemaDef["allowed-values"]);
|
||||
const webhookOnly = new Set(WEBHOOK_ONLY[eventName] || []);
|
||||
const schemaOnly = new Set(SCHEMA_ONLY[eventName] || []);
|
||||
const webhookToSchema = getWebhookToSchemaMapping(eventName);
|
||||
const schemaToWebhook = getSchemaToWebhookMapping(eventName);
|
||||
|
||||
// Direction 1: webhooks → schema
|
||||
// Check that each webhook type exists in schema (or has a mapping, or is webhook-only)
|
||||
for (const webhookType of webhookTypes) {
|
||||
if (webhookOnly.has(webhookType)) continue;
|
||||
|
||||
const mappedSchemaType = webhookToSchema.get(webhookType);
|
||||
if (mappedSchemaType) {
|
||||
// Has a mapping - check the mapped name exists in schema
|
||||
if (!schemaTypes.has(mappedSchemaType)) {
|
||||
mismatches.push(
|
||||
`Event "${eventName}": webhook type "${webhookType}" maps to "${mappedSchemaType}" but "${mappedSchemaType}" not found in schema`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No mapping - check the type exists directly
|
||||
if (!schemaTypes.has(webhookType)) {
|
||||
mismatches.push(
|
||||
`Event "${eventName}": missing activity type "${webhookType}" in workflow-v1.0.json (exists in webhooks.json)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direction 2: schema → webhooks
|
||||
// Check that each schema type exists in webhooks (or has a mapping, or is schema-only)
|
||||
const webhookTypesSet = new Set(webhookTypes);
|
||||
for (const schemaType of schemaTypes) {
|
||||
if (schemaOnly.has(schemaType)) continue;
|
||||
|
||||
const mappedWebhookType = schemaToWebhook.get(schemaType);
|
||||
if (mappedWebhookType) {
|
||||
// Has a mapping - check the mapped name exists in webhooks
|
||||
if (!webhookTypesSet.has(mappedWebhookType)) {
|
||||
mismatches.push(
|
||||
`Event "${eventName}": schema type "${schemaType}" maps to "${mappedWebhookType}" but "${mappedWebhookType}" not found in webhooks.json`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No mapping - check the type exists directly
|
||||
if (!webhookTypesSet.has(schemaType)) {
|
||||
mismatches.push(
|
||||
`Event "${eventName}": extra activity type "${schemaType}" in workflow-v1.0.json (not in webhooks.json)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check that the description mentions all allowed values
|
||||
const activityDefName = `${eventName.replace(/_/g, "-")}-activity`;
|
||||
const activityDef = schema.definitions[activityDefName];
|
||||
if (activityDef?.description) {
|
||||
for (const schemaType of schemaTypes) {
|
||||
if (!activityDef.description.includes(`\`${schemaType}\``)) {
|
||||
mismatches.push(
|
||||
`Event "${eventName}": description in "${activityDefName}" is missing activity type \`${schemaType}\``
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
const errorMessage = [
|
||||
"Activity type mismatches found between webhooks.json and workflow-v1.0.json:",
|
||||
"",
|
||||
...mismatches,
|
||||
"",
|
||||
"To fix these mismatches:",
|
||||
"1. Check GitHub docs: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows",
|
||||
"2. Verify the activity type is valid for workflow triggers",
|
||||
"3. Update the *-activity-type definition in workflow-parser/src/workflow-v1.0.json",
|
||||
"4. Update the description to list all supported activity types",
|
||||
"5. If there's a naming difference, add it to NAME_MAPPINGS in schema-sync.test.ts",
|
||||
"6. If the type is webhook-only, add it to WEBHOOK_ONLY",
|
||||
"7. If the type is schema-only, add it to SCHEMA_ONLY"
|
||||
].join("\n");
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -856,7 +856,7 @@
|
||||
}
|
||||
},
|
||||
"pull-request-activity": {
|
||||
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `locked`, `unlocked`, `enqueued`, `dequeued`, `milestoned`, `demilestoned`, `ready_for_review`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
||||
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `ready_for_review`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
||||
"one-of": [
|
||||
"pull-request-activity-type",
|
||||
"pull-request-activity-types"
|
||||
@@ -879,13 +879,9 @@
|
||||
"reopened",
|
||||
"synchronize",
|
||||
"converted_to_draft",
|
||||
"ready_for_review",
|
||||
"locked",
|
||||
"unlocked",
|
||||
"enqueued",
|
||||
"dequeued",
|
||||
"milestoned",
|
||||
"demilestoned",
|
||||
"ready_for_review",
|
||||
"review_requested",
|
||||
"review_request_removed",
|
||||
"auto_merge_enabled",
|
||||
@@ -1008,7 +1004,7 @@
|
||||
}
|
||||
},
|
||||
"pull-request-target-activity": {
|
||||
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `locked`, `unlocked`, `enqueued`, `dequeued`, `milestoned`, `demilestoned`, `ready_for_review`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
||||
"description": "The types of pull request activity that trigger the workflow. Supported activity types: `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `closed`, `reopened`, `synchronize`, `converted_to_draft`, `ready_for_review`, `locked`, `unlocked`, `review_requested`, `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.",
|
||||
"one-of": [
|
||||
"pull-request-target-activity-type",
|
||||
"pull-request-target-activity-types"
|
||||
@@ -1031,13 +1027,9 @@
|
||||
"reopened",
|
||||
"synchronize",
|
||||
"converted_to_draft",
|
||||
"ready_for_review",
|
||||
"locked",
|
||||
"unlocked",
|
||||
"enqueued",
|
||||
"dequeued",
|
||||
"milestoned",
|
||||
"demilestoned",
|
||||
"ready_for_review",
|
||||
"review_requested",
|
||||
"review_request_removed",
|
||||
"auto_merge_enabled",
|
||||
@@ -1547,7 +1539,7 @@
|
||||
},
|
||||
"default": "workflow-dispatch-input-default",
|
||||
"options": {
|
||||
"type": "sequence-of-string",
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"description": "The options of the dropdown list, if the type is a choice."
|
||||
}
|
||||
}
|
||||
@@ -2427,11 +2419,6 @@
|
||||
"item-type": "non-empty-string"
|
||||
}
|
||||
},
|
||||
"sequence-of-string": {
|
||||
"sequence": {
|
||||
"item-type": "string"
|
||||
}
|
||||
},
|
||||
"boolean-needs-context": {
|
||||
"context": [
|
||||
"github",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {JSONObjectReader} from "../templates/json-object-reader";
|
||||
import {TemplateSchema} from "../templates/schema";
|
||||
import WorkflowSchema from "../workflow-v1.0.min.json";
|
||||
import WorkflowSchema from "../workflow-v1.0.json";
|
||||
|
||||
let schema: TemplateSchema;
|
||||
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import {
|
||||
isAlias,
|
||||
isCollection,
|
||||
isDocument,
|
||||
isMap,
|
||||
isPair,
|
||||
isScalar,
|
||||
isSeq,
|
||||
LineCounter,
|
||||
parseDocument,
|
||||
Scalar
|
||||
} from "yaml";
|
||||
import type {Document} from "yaml";
|
||||
import {isCollection, isDocument, isMap, isPair, isScalar, isSeq, LineCounter, parseDocument, Scalar} from "yaml";
|
||||
import type {LinePos} from "yaml/dist/errors";
|
||||
import type {NodeBase} from "yaml/dist/nodes/Node";
|
||||
import {ObjectReader} from "../templates/object-reader";
|
||||
@@ -34,31 +22,30 @@ export type YamlError = {
|
||||
export class YamlObjectReader implements ObjectReader {
|
||||
private readonly _generator: Generator<ParseEvent>;
|
||||
private _current!: IteratorResult<ParseEvent>;
|
||||
private readonly doc: Document;
|
||||
private fileId?: number;
|
||||
private lineCounter = new LineCounter();
|
||||
|
||||
public errors: YamlError[] = [];
|
||||
|
||||
constructor(fileId: number | undefined, content: string) {
|
||||
this.doc = parseDocument(content, {
|
||||
const doc = parseDocument(content, {
|
||||
lineCounter: this.lineCounter,
|
||||
keepSourceTokens: true,
|
||||
uniqueKeys: false // Uniqueness is validated by the template reader
|
||||
});
|
||||
for (const err of this.doc.errors) {
|
||||
for (const err of doc.errors) {
|
||||
this.errors.push({message: err.message, range: rangeFromLinePos(err.linePos)});
|
||||
}
|
||||
this._generator = this.getNodes(this.doc, new Set());
|
||||
this._generator = this.getNodes(doc);
|
||||
this.fileId = fileId;
|
||||
}
|
||||
|
||||
private *getNodes(node: unknown, aliasResolutionStack: Set<unknown>): Generator<ParseEvent, void> {
|
||||
private *getNodes(node: unknown): Generator<ParseEvent, void> {
|
||||
let range = this.getRange(node as NodeBase | undefined);
|
||||
|
||||
if (isDocument(node)) {
|
||||
yield new ParseEvent(EventType.DocumentStart);
|
||||
for (const item of this.getNodes(node.contents, new Set())) {
|
||||
for (const item of this.getNodes(node.contents)) {
|
||||
yield item;
|
||||
}
|
||||
yield new ParseEvent(EventType.DocumentEnd);
|
||||
@@ -72,7 +59,7 @@ export class YamlObjectReader implements ObjectReader {
|
||||
}
|
||||
|
||||
for (const item of node.items) {
|
||||
for (const child of this.getNodes(item, aliasResolutionStack)) {
|
||||
for (const child of this.getNodes(item)) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
@@ -87,32 +74,12 @@ export class YamlObjectReader implements ObjectReader {
|
||||
yield new ParseEvent(EventType.Literal, YamlObjectReader.getLiteralToken(this.fileId, range, node));
|
||||
}
|
||||
|
||||
// Handle YAML aliases - resolve to the anchored value
|
||||
if (isAlias(node)) {
|
||||
const resolved = node.resolve(this.doc);
|
||||
if (resolved) {
|
||||
// Prevent infinite recursion from circular aliases
|
||||
if (aliasResolutionStack.has(resolved)) {
|
||||
// Silently ignore circular reference - the missing content will cause
|
||||
// downstream validation errors which is acceptable for this edge case
|
||||
return;
|
||||
}
|
||||
// Track this node in the alias resolution stack
|
||||
const newStack = new Set(aliasResolutionStack);
|
||||
newStack.add(resolved);
|
||||
// Yield the resolved node's contents
|
||||
yield* this.getNodes(resolved, newStack);
|
||||
}
|
||||
// If unresolved, the yaml library already reports an error
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPair(node)) {
|
||||
const scalarKey = node.key as Scalar;
|
||||
range = this.getRange(scalarKey);
|
||||
const key = scalarKey.value as string;
|
||||
yield new ParseEvent(EventType.Literal, new StringToken(this.fileId, range, key, undefined));
|
||||
for (const child of this.getNodes(node.value, aliasResolutionStack)) {
|
||||
for (const child of this.getNodes(node.value)) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,8 +120,6 @@ on:
|
||||
- unassigned
|
||||
- labeled
|
||||
- unlabeled
|
||||
- milestoned
|
||||
- demilestoned
|
||||
- opened
|
||||
- edited
|
||||
- closed
|
||||
@@ -131,8 +129,6 @@ on:
|
||||
- ready_for_review
|
||||
- locked
|
||||
- unlocked
|
||||
- enqueued
|
||||
- dequeued
|
||||
- review_requested
|
||||
- review_request_removed
|
||||
- auto_merge_enabled
|
||||
@@ -164,8 +160,6 @@ on:
|
||||
- unassigned
|
||||
- labeled
|
||||
- unlabeled
|
||||
- milestoned
|
||||
- demilestoned
|
||||
- opened
|
||||
- edited
|
||||
- closed
|
||||
@@ -175,8 +169,6 @@ on:
|
||||
- ready_for_review
|
||||
- locked
|
||||
- unlocked
|
||||
- enqueued
|
||||
- dequeued
|
||||
- review_requested
|
||||
- review_request_removed
|
||||
- auto_merge_enabled
|
||||
@@ -394,8 +386,6 @@ jobs:
|
||||
"unassigned",
|
||||
"labeled",
|
||||
"unlabeled",
|
||||
"milestoned",
|
||||
"demilestoned",
|
||||
"opened",
|
||||
"edited",
|
||||
"closed",
|
||||
@@ -405,8 +395,6 @@ jobs:
|
||||
"ready_for_review",
|
||||
"locked",
|
||||
"unlocked",
|
||||
"enqueued",
|
||||
"dequeued",
|
||||
"review_requested",
|
||||
"review_request_removed",
|
||||
"auto_merge_enabled",
|
||||
@@ -453,8 +441,6 @@ jobs:
|
||||
"unassigned",
|
||||
"labeled",
|
||||
"unlabeled",
|
||||
"milestoned",
|
||||
"demilestoned",
|
||||
"opened",
|
||||
"edited",
|
||||
"closed",
|
||||
@@ -464,8 +450,6 @@ jobs:
|
||||
"ready_for_review",
|
||||
"locked",
|
||||
"unlocked",
|
||||
"enqueued",
|
||||
"dequeued",
|
||||
"review_requested",
|
||||
"review_request_removed",
|
||||
"auto_merge_enabled",
|
||||
|
||||
Reference in New Issue
Block a user