Compare commits

..
289 changed files with 140773 additions and 54754 deletions
-3
View File
@@ -1,4 +1 @@
* @actions/actions-vscode-reviewers * @actions/actions-vscode-reviewers
# Owners maintaining https://github.com/actions/runner-images
/languageservice/src/value-providers/default.ts @actions/runner-images-writers @actions/actions-vscode-reviewers
+3 -40
View File
@@ -12,55 +12,18 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js 16.15
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: 16.15
cache: 'npm' cache: 'npm'
registry-url: 'https://npm.pkg.github.com' registry-url: 'https://npm.pkg.github.com'
- run: npm ci --engine-strict - run: npm ci
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm run format-check -ws - run: npm run format-check -ws
- run: npm run build -ws - run: npm run build -ws
- run: npm run lint -ws - run: npm run lint -ws
- run: npm test -ws - run: npm test -ws
check-generated:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 24.x
uses: actions/setup-node@v4
with:
node-version: 24.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
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24.x node-version: "16"
- name: Bump version and push - name: Bump version and push
run: | run: |
+11 -3
View File
@@ -59,7 +59,7 @@ jobs:
permissions: permissions:
contents: write contents: write
id-token: write packages: write
env: env:
PKG_VERSION: "" # will be set in the workflow PKG_VERSION: "" # will be set in the workflow
@@ -69,8 +69,9 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24.x node-version: 16.x
cache: "npm" cache: "npm"
scope: '@actions'
- name: Parse version from lerna.json - name: Parse version from lerna.json
run: | run: |
@@ -96,6 +97,13 @@ jobs:
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url); core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
await core.summary.write(); await core.summary.write();
- name: setup authentication
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish packages - name: Publish packages
run: | run: |
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
-11
View File
@@ -3,14 +3,3 @@
lerna-debug.log lerna-debug.log
node_modules node_modules
.DS_Store .DS_Store
# Nx cache (generated by Lerna/Nx)
.nx/
# Minified JSON (generated at build time)
*.min.json
# Intermediate JSON for size comparison (generated by update-webhooks --all)
*.all.json
*.drop.json
*.strip.json
-1
View File
@@ -4,4 +4,3 @@ dist
*.js *.js
*.json *.json
*.d.ts *.d.ts
/.nx/workspace-data
-33
View File
@@ -1,33 +0,0 @@
# Agents
## Build
```
npx lerna run build
```
## Test
```
npm -w @actions/expressions test
npm -w @actions/workflow-parser test
npm -w @actions/languageservice test
```
## Format
Always run formatting before committing:
```
npx prettier --write <changed files>
```
Verify with:
```
npm run format-check -ws
```
## Feature flags
Feature flags are defined in `expressions/src/features.ts` (`ExperimentalFeatures` interface + `allFeatureKeys` array). They are plumbed through `ConvertOptions`, `CompletionConfig`, `ValidationConfig`, and `initializationOptions`. When a feature graduates to stable, remove its flag and make the behavior unconditional.
+666
View File
@@ -0,0 +1,666 @@
# Bundle Size Investigation
## Current State
**Package sizes on disk (in github-ui node_modules):**
- `@actions/languageservice`: **7.9M**
- `@actions/workflow-parser`: **1.5M**
- `@actions/expressions`: **560K**
- **Total: ~10M**
**Largest files:**
| File | Size | % of total |
|------|------|------------|
| `languageservice/dist/context-providers/events/webhooks.json` | 6.2M | 62% |
| `languageservice/dist/context-providers/events/objects.json` | 948K | 9.5% |
| `workflow-parser/dist/workflow-v1.0.json` | 112K | 1% |
| `languageservice/dist/context-providers/descriptions.json` | 20K | <1% |
## JSON File Analysis
### What `webhooks.json` is used for
Provides autocomplete and validation for `github.*` context expressions. When you type `${{ github.event.` the language service uses this data to:
- Suggest available properties based on event type (push, pull_request, etc.)
- Provide descriptions for hover tooltips
- Validate property access is valid for the event type
### Field usage analysis
| Field | Location | Size | Used for Autocomplete | Used for Validation | Used for Hover |
|-------|----------|------|----------------------|---------------------|----------------|
| `bodyParameters[].description` | Inside each param | Part of bodyParams | ✅ Documentation popup | ✅ Property existence | ✅ Descriptions |
| `bodyParameters[].name/type/etc` | Inside each param | 1.55 MB total | ✅ Property names | ✅ Property existence | ✅ Structure |
| `description` | Top-level on event | 17 KB | ❌ Defined but unused | ❌ | ❌ |
| `summary` | Top-level on event | 155 KB | ❌ | ❌ | ❌ |
| `availability` | Top-level on event | 7 KB | ❌ | ❌ | ❌ |
| `category` | Top-level on event | 3 KB | ❌ | ❌ | ❌ |
| `action` | Top-level on event | 2 KB | ❌ | ❌ | ❌ |
**Key insight:** `bodyParameters` (including nested `description` fields) is used for ALL features. The **top-level** fields (`summary`, `description`, `availability`, `category`, `action`) are defined in the TypeScript types but never actually accessed in code - they can be stripped.
### Why top-level `description`/`summary` shouldn't be used for workflow events
**Question:** Could we use the webhooks.json top-level `description` or `summary` fields to enhance autocomplete/hover for the `on:` field?
**Answer:** No - they serve different purposes and the existing solution is better.
**Comparison:**
| Source | Example for `push` | Purpose |
|--------|-------------------|---------|
| `workflow-v1.0.json` (current) | "Runs your workflow when you push a commit or tag." | **User-facing** - explains what triggers the workflow |
| `webhooks.json` description | "A push was made to a repository branch..." | **API-facing** - describes the GitHub API event |
| `webhooks.json` summary | "This event occurs when a commit or tag is pushed. To subscribe to this event, a GitHub App must have at least read-level access..." | **App developer-facing** - API permissions info |
**The current solution is correct:**
- `workflow-v1.0.json` contains workflow-specific event descriptions written for GitHub Actions users
- These are shown in autocomplete/hover when completing `on: push`, `on: pull_request`, etc.
- Located in `languageservice/src/value-providers/definition.ts` line 46: `description: def.description`
**The webhooks.json descriptions would be wrong:**
- Written for GitHub App developers, not GitHub Actions users
- Include irrelevant details (API permissions, subscription info)
- Don't explain what happens in the context of a workflow
**Conclusion:** Keep the top-level fields stripped - they're not needed and would be confusing if used.
### Minification analysis
| File | Pretty Size | Minified Size | Savings |
|------|-------------|---------------|---------|
| `webhooks.json` | 4.1 MB | 1.6 MB | **2.5 MB (60.5%)** |
| `objects.json` | 666 KB | 325 KB | **341 KB (51.3%)** |
| `workflow-v1.0.json` | 91 KB | 70 KB | **22 KB (23.5%)** |
**The files are NOT minified!** Just minifying saves 60%.
### Compression analysis (gzip)
Production servers typically gzip assets. Here's what matters for network transfer:
| File | Original | Minified | Gzipped | Min+Gzip |
|------|----------|----------|---------|----------|
| `webhooks.json` | 4.0 MB | 1.6 MB | 198 KB | **90 KB** |
| `objects.json` | 651 KB | 317 KB | 38 KB | **23 KB** |
| `workflow-v1.0.json` | 91 KB | 70 KB | 13 KB | **13 KB** |
**What matters for different concerns:**
| Concern | What matters |
|---------|--------------|
| **Network transfer** | Compressed size (gzip/brotli) - already small (~126 KB total) |
| **npm package size** | Uncompressed size on disk - affects install times |
| **Memory usage** | Parsed JSON object size in memory |
| **Parse time** | Uncompressed size (must decompress before parsing) |
**Key insight:** Network transfer is NOT the main concern (~126 KB gzipped). Minifying still matters for:
- Smaller npm package size (better install times)
- Less to decompress on client
- Faster JSON parsing (less text to parse)
## How the files are generated
The JSON files are **auto-generated** from GitHub's official REST API description:
```
npm run update-webhooks
```
**Source:** `github:github/rest-api-description` (GitHub's OpenAPI spec)
**Generation script:** `languageservice/script/webhooks/index.ts`
- Reads webhook definitions from the dereferenced OpenAPI schema
- Extracts body parameters, descriptions, summaries
- Runs deduplication to create `objects.json` (shared parameters stored once, referenced by index)
- Outputs pretty-printed JSON (not minified)
**Current deduplication strategy (`deduplicate.ts`):**
- Finds body parameters that appear in multiple webhooks
- Stores them once in `objects.json` array
- Replaces duplicates with numeric index references in `webhooks.json`
**Optimization opportunities in generation:**
1. Add minification step (remove whitespace) - easy, ~60% savings
2. Strip unused fields (`summary`, `availability`, `category`, `action`) - ~10% additional savings
3. Consider more aggressive deduplication (e.g., dedupe descriptions, nested objects)
### `workflow-v1.0.json` (workflow schema)
**Hand-authored** - not generated. Located in `workflow-parser/src/`.
Optimization: Minify at build time (112K pretty → smaller minified).
### Other Small JSON Files
| File | Purpose | Pretty | Minified | Further Optimized |
|------|---------|--------|----------|-------------------|
| `descriptions.json` | Hover descriptions for contexts/functions | 18 KB | 17 KB | N/A (all used) |
| `schedule.json` | Sample `github.event` for schedule trigger | 5.7 KB | 5.1 KB | **1.8 KB** (strip values) |
| `workflow_call.json` | Sample `github.event` for reusable workflows | 7.3 KB | 6.5 KB | **2.3 KB** (strip values) |
**Why `schedule.json` / `workflow_call.json` exist:**
These events are NOT webhooks - they're internal GitHub Actions triggers that don't appear in the REST API webhook definitions. The files provide sample `github.event` payloads so the language service knows what properties to autocomplete:
```
User types: ${{ github.event.repository.owner.login }}
Language service walks schedule.json to find valid property names
```
The code (`eventPayloads.ts` lines 109-116) uses `mergeObject()` to recursively extract property **names** - the actual values are never used.
**Key insight for `schedule.json` / `workflow_call.json`:** These files provide sample event payloads. The code only uses property **names** (for autocomplete like `github.event.repository.owner.login`), not values. The actual values (URLs, IDs, emails) can be replaced with `null`:
```javascript
// Original (5.1 KB)
{"repository":{"id":186853002,"name":"Hello-World","owner":{"login":"Codertocat",...},...},...}
// Stripped (1.8 KB) - same autocomplete functionality
{"repository":{"id":null,"name":null,"owner":{"login":null,...},...},...}
```
**Savings:** ~65% smaller for these files.
## JSON File Maintenance & Documentation
### TODO: Document maintenance procedures
| File | Source | How to Update | Documented? |
|------|--------|---------------|-------------|
| `webhooks.json` + `objects.json` | `npm run update-webhooks` from `rest-api-description` | Run script | ⚠️ Partial (in script) |
| `workflow-v1.0.json` | Hand-authored | Manual edits | ❌ No |
| `descriptions.json` | Hand-authored | Manual edits | ❌ No |
| `schedule.json` | Hand-authored sample payload | Manual edits | ❌ No - unclear origin |
| `workflow_call.json` | Hand-authored sample payload | Manual edits | ❌ No - unclear origin |
### Historical context (from git history):
- **`schedule.json`** - Added in commit `b68ac91` (Dec 2022) by Beth Brennan in "Use payload schema for events"
- Uses "Codertocat/Hello-World" sample data (appears to be from GitHub's webhook documentation examples)
- No documentation on where this came from or how to update it
- **Question:** Is this based on a real scheduled workflow run? How do we know it includes all possible properties?
- **`workflow_call.json`** - Same commit, similar questions
- **Many other event JSON files** were added in that same commit, but were later replaced by the generated `webhooks.json` system. Only `schedule.json` and `workflow_call.json` remain as manual files because they're not real webhooks.
### Questions to answer:
1. **`schedule.json`** - Where did this sample payload come from? Is it based on a real event? How do we know it's complete/accurate? Does it need updating when GitHub adds new repository properties?
2. **`workflow_call.json`** - Same questions. Was this captured from an actual workflow run?
3. **`descriptions.json`** - Are these descriptions synced from docs.github.com or manually maintained? How do we keep them up to date?
4. **`workflow-v1.0.json`** - What's the process for adding new workflow syntax (new keys, new event types)?
### Recommended actions:
1. **Add README files** - Each JSON file should have documentation explaining what it's for, how to update it, and who maintains it
2. **Automate where possible** - Could `schedule.json` be generated from a real scheduled workflow run's `github.event`? Could we capture a sample automatically?
3. **Add tests** - Validate that sample payloads match expected structure
### ⚠️ BUG: `workflow_call.json` may be incorrect/useless
**Finding:** For `on: workflow_call` (reusable workflows), the `github.event` context is **inherited from the calling workflow**. If the caller was triggered by `push`, then `github.event` contains push data. If by `pull_request`, it contains PR data.
**Current behavior in `github.ts`:**
```typescript
// Line 87-89 - For VALIDATION mode, returns Null (any value allowed)
if (eventsConfig.workflow_call && mode == Mode.Validation) {
return new data.Null();
}
// But for COMPLETION/HOVER mode, falls through and uses workflow_call.json!
```
**Problem:** `workflow_call.json` contains generic repo/sender/org data, but this is WRONG for autocomplete. When you type `${{ github.event.` in a reusable workflow, showing `repository`, `sender`, etc. is misleading because:
- The actual properties depend on how the workflow was called
- Could be push properties, PR properties, or anything else
**Recommendation:**
- Either return `Null` for completion/hover too (show nothing, since we can't know)
- Or remove `workflow_call.json` entirely since it's actively misleading
- This would save 7KB and fix a bug!
## npm Package Sizes
The actual npm package sizes (gzipped tarballs) are much smaller than disk size:
| Package | Disk Size | Package Size (gzipped) | Unpacked |
|---------|-----------|------------------------|----------|
| `@actions/languageservice` | 7.9M | **368 KB** | 7.7 MB |
| `@actions/workflow-parser` | 1.5M | **98 KB** | 548 KB |
| `@actions/expressions` | 560K | **34 KB** | 153 KB |
| **Total** | ~10M | **~500 KB** | ~8.4 MB |
**Key insight:** npm install downloads ~500KB gzipped. The disk/memory impact is ~8.4 MB unpacked.
## Dependencies Analysis
**Direct dependencies:**
| Package | Disk Size | Used By | Notes |
|---------|-----------|---------|-------|
| `yaml` | 1.4 MB | workflow-parser, languageservice | Full YAML parser, well-structured |
| `cronstrue` | 1.4 MB | workflow-parser | Cron → human text. Main: 44KB (no i18n) |
| `vscode-languageserver-types` | 396 KB | languageservice | Type definitions for LSP |
| `vscode-languageserver-textdocument` | 72 KB | languageservice | Text document handling |
| `vscode-uri` | 256 KB | languageservice | URI parsing |
**Observations:**
- `cronstrue` has a 44KB main entry (without i18n) vs 238KB with i18n. Bundlers should use the smaller one.
- `yaml` is necessary - no lighter alternative for full YAML parsing
- `vscode-*` packages are minimal and necessary for LSP compatibility
## Areas to Investigate
1.**Total bundle size** - Analyzed above
2.**Specific heavy dependencies** - `cronstrue` and `yaml` analyzed
3. **Tree-shaking** - Whether unused code is being properly eliminated
4.**Load time impact** - Lazy-loaded in github-ui via dynamic import()
5.**JSON files for event validation** - Main culprit (6.2MB webhooks.json)
6.**Minifying the workflow schema JSON file** - 112K → can be minified
## Potential Optimizations
### High Impact
1. **Drop 31 unused webhook events** - Events like `installation`, `marketplace_purchase`, `sponsorship`, `star`, `team`, etc. are in `webhooks.json` but cannot be used as workflow triggers. Confirmed against [GitHub's official docs](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows).
| Metric | Before | After | Savings |
|--------|--------|-------|---------|
| Events | 63 | 32 | 31 dropped |
| Size | 1.76 MB | 1.42 MB | **19%** |
**Events to drop:**
```
code_scanning_alert, commit_comment, dependabot_alert, deploy_key,
github_app_authorization, installation, installation_repositories,
installation_target, marketplace_purchase, member, membership, meta,
org_block, organization, package, ping, projects_v2, projects_v2_item,
pull_request_review_thread, repository, repository_import,
repository_vulnerability_alert, secret_scanning_alert,
secret_scanning_alert_location, security_advisory, security_and_analysis,
sponsorship, star, team, team_add, workflow_job
```
2. **Strip unused fields** - Remove `summary`, `availability`, `category`, `action` fields that are never used by the language service. Only `bodyParameters` and `descriptionHtml` are needed.
3. **Minify JSON files** - Currently pretty-printed with whitespace. Minifying saves ~60%.
4. **Combined impact estimate:**
| Optimization | webhooks.json | objects.json |
|--------------|---------------|--------------|
| Original | 6.2 MB | 948 KB |
| Drop unused events | 5.0 MB (-19%) | 770 KB (-19%) |
| Strip unused fields | 3.0 MB (-40%) | 460 KB (-40%) |
| Minify | 1.2 MB (-60%) | 225 KB (-52%) |
| **Gzipped (network)** | **~60 KB** | **~20 KB** |
5. **Add `"sideEffects"` to all package.json files** - Enable tree-shaking across all packages:
- `expressions/package.json`: `"sideEffects": false`
- `workflow-parser/package.json`: `"sideEffects": false`
- `languageservice/package.json`: `"sideEffects": ["./dist/context-providers/events/eventPayloads.js"]`
### Medium Impact
6. **Minify `workflow-v1.0.json` schema (112K)** - Strip whitespace. Note: This file is hand-authored, not generated from webhook data.
7. **Minify and strip small JSON files** - `schedule.json`, `descriptions.json`:
- Minify all (remove whitespace)
- Strip values from `schedule.json` (only property names are used)
8. **Investigate `workflow_call.json` usage** - See bug section above. This file may be incorrect/useless:
- For `on: workflow_call`, `github.event` is inherited from the calling workflow
- Current code returns `Null` for validation (correct) but uses `workflow_call.json` for completion (incorrect?)
- Options: Remove file entirely, or fix code to return `Null` for all modes
- Saves 7KB + potentially fixes misleading autocomplete
9. **Lazy-load event validation data** - Refactor `eventPayloads.ts` to load JSON on first use instead of at import time.
### Low Impact / Further Investigation
10. **Tree-shake unused exports** - Ensure webpack is eliminating dead code.
11. **Evaluate `cronstrue` size** - Check if it's worth keeping or replacing with lighter alternative.
11. **Bundle analysis** - Run webpack-bundle-analyzer to see actual bundled sizes after minification/compression.
## Implementation Plan
### Phase 1: Update generation script (`languageservice/script/webhooks/index.ts`)
1. Add list of valid workflow trigger events (whitelist)
2. Filter out events not in whitelist during generation
3. Strip unused fields (`summary`, `availability`, `category`, `action`)
4. Output minified JSON (`JSON.stringify(data)` instead of `JSON.stringify(data, null, 2)`)
### Phase 1b: Minify/optimize small hand-authored JSON files
1. Minify `descriptions.json` (18 KB → 17 KB)
2. Strip values & minify `schedule.json` (5.7 KB → 1.8 KB)
3. Strip values & minify `workflow_call.json` (7.3 KB → 2.3 KB)
4. Minify `workflow-v1.0.json` (112 KB → ~90 KB)
### Phase 2: Add sideEffects to all package.json files
1. Add `"sideEffects": false` to `expressions/package.json`
2. Add `"sideEffects": false` to `workflow-parser/package.json`
3. Add `"sideEffects": ["./dist/context-providers/events/eventPayloads.js"]` to `languageservice/package.json`
### Phase 3: (Optional) Refactor for lazy loading
1. Move JSON imports inside functions
2. Remove top-level hydration code, make it lazy
### Phase 4: Automated JSON updates via GitHub Actions
Create workflows to automatically keep JSON files up to date:
#### 4a: Webhook JSON auto-update workflow
```yaml
# .github/workflows/update-webhooks.yml
name: Update webhook definitions
on:
schedule:
- cron: '0 0 * * 1' # Weekly on Monday
workflow_dispatch: # Manual trigger
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run update-webhooks
- name: Create PR if changes
uses: peter-evans/create-pull-request@v5
with:
title: "chore: Update webhook definitions"
body: |
Automated update from `rest-api-description` package.
This PR was created automatically by the update-webhooks workflow.
branch: auto/update-webhooks
delete-branch: true # Delete old branch, creates fresh PR each time
commit-message: "chore: Update webhook definitions"
```
#### 4b: Schedule/workflow_call JSON auto-update workflow
Create a workflow that runs an actual scheduled workflow and captures `github.event`:
```yaml
# .github/workflows/capture-schedule-payload.yml
name: Capture schedule event payload
on:
schedule:
- cron: '0 0 1 * *' # Monthly on the 1st
workflow_dispatch:
jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Capture github.event
run: |
echo '${{ toJSON(github.event) }}' > /tmp/schedule-event.json
# Strip to just property structure (values → null)
node -e "
const fs = require('fs');
const strip = (o) => {
if (Array.isArray(o)) return o.length ? [strip(o[0])] : [];
if (o && typeof o === 'object') return Object.fromEntries(
Object.entries(o).map(([k,v]) => [k, strip(v)])
);
return null;
};
const data = JSON.parse(fs.readFileSync('/tmp/schedule-event.json'));
const stripped = strip(data);
fs.writeFileSync(
'languageservice/src/context-providers/events/schedule.json',
JSON.stringify(stripped, null, 2)
);
"
- name: Create PR if changes
uses: peter-evans/create-pull-request@v5
with:
title: "chore: Update schedule.json payload structure"
body: |
Captured fresh `github.event` structure from a real scheduled workflow run.
This ensures autocomplete suggestions match the actual event payload.
branch: auto/update-schedule-json
delete-branch: true
commit-message: "chore: Update schedule.json from live event"
```
#### 4c: Workflow_call payload capture
Similar approach - create a reusable workflow that calls itself and captures `github.event`:
```yaml
# .github/workflows/capture-workflow-call-payload.yml
name: Capture workflow_call event payload
on:
workflow_call:
workflow_dispatch:
jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Capture and update workflow_call.json
if: github.event_name == 'workflow_call'
run: |
# Similar to schedule capture above
echo '${{ toJSON(github.event) }}' | node -e "..." > workflow_call.json
- name: Trigger self as reusable workflow
if: github.event_name == 'workflow_dispatch'
uses: ./.github/workflows/capture-workflow-call-payload.yml
```
**Benefits:**
- JSON files stay up to date automatically
- PRs are created for review (not auto-merged)
- Captures real event structures, not guessed samples
- Weekly/monthly schedule catches GitHub API changes
## Validation Stages Analysis
The current `validate()` function does everything in one pass. We could split it into stages that load progressively:
### Current Loading Cascade
```
validate() called
└─ imports workflow-parser
└─ imports workflow-v1.0.json (112KB) ← loaded immediately
└─ parseWorkflow() → YAML parse + schema validation
└─ additionalValidations()
└─ getContext() → imports github.ts
└─ imports eventPayloads.ts
└─ imports webhooks.json (6.2MB) ← loaded immediately
```
### Potential Validation Stages
| Stage | What it validates | Data needed | Size |
|-------|-------------------|-------------|------|
| **1. YAML Syntax** | Valid YAML? Quotes closed? Indentation? | YAML parser (bundled) | ~0 |
| **2. Workflow Schema** | Valid `jobs:`, `steps:`, `runs-on:`? | `workflow-v1.0.json` | 112KB |
| **3. Expression Syntax** | Valid `${{ }}` syntax? Functions exist? | Expression parser | ~0 |
| **4. Context Validation** | `github.sha`, `env.FOO` exist? | Just code | ~0 |
| **5. Event Payload Validation** | `github.event.pull_request.title` exists? | `webhooks.json` | 6.2MB |
### Key Insight
Stages 1-4 can run with minimal data (~112KB). Only Stage 5 needs the 6.2MB webhook data.
**Expression syntax** (`${{ secrets.FOO }}`) is different from **event payload validation** (`${{ github.event.issue.number }}`):
- Expression syntax: Is this a valid expression? Does the function exist?
- Event payload: Does this specific property exist on the `pull_request` event?
### Options for Progressive Loading
**Option A: Lazy load webhooks.json (simplest)**
```typescript
// eventPayloads.ts - defer import until first use
let webhooksData: Webhooks | null = null;
async function getWebhooks() {
if (!webhooksData) {
const { default: data } = await import("./webhooks.json");
webhooksData = data;
}
return webhooksData;
}
```
- Pro: Minimal code changes
- Con: Still blocks when github.event.* is first accessed
**Option B: Multi-pass validation in languageservice**
```typescript
// New exports from @actions/languageservice
export { validateSchema } from "./validate-schema"; // Fast
export { validateExpressions } from "./validate-expressions"; // Needs webhooks
export { validate } from "./validate"; // Combined (current)
```
- Pro: Clean API, consumer controls loading
- Con: More work, API change
**Option C: Multi-pass validation in github-ui**
```typescript
// github-ui can show partial results
const schemaErrors = await validate(doc); // Returns what it can immediately
// Later, more errors may arrive as webhooks.json loads
```
- Pro: No languageservice changes
- Con: Complex state management in consumer
### Recommendation
1. **Phase 1**: Minify + strip unused data (reduce 6.2MB → ~1.2MB)
2. **Phase 2**: Lazy load webhooks.json in `eventPayloads.ts`
3. **Phase 3** (future): Consider multi-pass API if needed
The lazy loading approach gives 90% of the benefit with 10% of the complexity.
## Side Effects Analysis
Need to verify the packages have no side effects before adding `"sideEffects": false`:
- [x] `@actions/languageservice` - Has ONE file with side effects
- [x] `@actions/workflow-parser` - ✅ No side effects
- [x] `@actions/expressions` - ✅ No side effects
Common side effects to look for:
- Top-level function calls (not just definitions)
- Modifying global objects (`Object.prototype`, `window`, etc.)
- Polyfills
- CSS imports (not applicable here)
### JSON Files Imported at Top Level
| Package | File | JSON Imported | Size | Has Side Effects? |
|---------|------|---------------|------|-------------------|
| languageservice | `eventPayloads.ts` | `webhooks.json` | 6.2 MB | ⚠️ YES (mutation) |
| languageservice | `eventPayloads.ts` | `objects.json` | 948 KB | ⚠️ YES (mutation) |
| languageservice | `eventPayloads.ts` | `schedule.json` | 6 KB | ⚠️ YES (mutation) |
| languageservice | `eventPayloads.ts` | `workflow_call.json` | 8 KB | ⚠️ YES (mutation) |
| languageservice | `descriptions.ts` | `descriptions.json` | 20 KB | ❌ No |
| workflow-parser | `workflow-schema.ts` | `workflow-v1.0.json` | 112 KB | ❌ No |
| expressions | (none) | (none) | - | ❌ No |
### Findings
**`@actions/expressions`** - ✅ No side effects
- No JSON imports
- No top-level code execution
- Can use `"sideEffects": false`
**`@actions/workflow-parser`** - ✅ No side effects
- `workflow-schema.ts` imports `workflow-v1.0.json` at top level BUT:
- Only exports a function `getWorkflowSchema()` with lazy initialization
- No top-level function calls or mutations
- Can use `"sideEffects": false`
**`@actions/languageservice`** - ⚠️ HAS ONE FILE with side effects
`descriptions.ts` - ❌ No side effects
- Imports `descriptions.json` (20KB) at top level
- Only exports functions, no top-level execution
`eventPayloads.ts` - ⚠️ HAS SIDE EFFECTS
```typescript
// Lines 3-7: JSON imports at top level (7.2MB total)
import webhookObjects from "./objects.json";
import webhooks from "./webhooks.json";
import schedule from "./schedule.json";
import workflow_call from "./workflow_call.json";
// Lines 85-93: Executes at module load time, mutates data
getWebhookPayload("workflow_dispatch", "default");
const inputs = webhookPayloads?.["workflow_dispatch"]?.["default"].bodyParameters.find(p => p.name === "inputs");
if (inputs) {
delete inputs.childParamsGroups;
}
```
### Recommended `sideEffects` Configuration
**`expressions/package.json`:**
```json
"sideEffects": false
```
**`workflow-parser/package.json`:**
```json
"sideEffects": false
```
**`languageservice/package.json`:**
```json
"sideEffects": ["./dist/context-providers/events/eventPayloads.js"]
```
**Impact:** Allows webpack to tree-shake unused exports. Without this, webpack assumes all imports may have side effects and keeps everything.
### Optional: Refactor `eventPayloads.ts` to Remove Side Effects
To allow `"sideEffects": false` for the entire languageservice package, refactor the mutation code:
```typescript
// Before: Top-level mutation
getWebhookPayload("workflow_dispatch", "default");
const inputs = webhookPayloads?.["workflow_dispatch"]?.["default"].bodyParameters.find(p => p.name === "inputs");
if (inputs) {
delete inputs.childParamsGroups;
}
// After: Lazy initialization inside function
let initialized = false;
function ensureInitialized() {
if (initialized) return;
initialized = true;
// ... mutation code here
}
export function getEventPayload(...) {
ensureInitialized();
// ... rest of function
}
```
This would allow full tree-shaking AND defer the 7.2MB JSON load until first use.
+153
View File
@@ -0,0 +1,153 @@
# Bundle Size Optimization Plan
## Goal
Reduce `@actions/languageservice` package size from **7.9 MB** to **~1.5 MB** (80% reduction).
## Summary
| Phase | Change | Savings | Effort |
|-------|--------|---------|--------|
| 1a | Minify all JSON | 60% | Low |
| 1b | Strip unused fields | 10% | Low |
| 1c | Drop unused events | 19% | Low |
| 2 | Lazy-load webhooks.json (optional) | Faster initial load | Medium |
## Phase 1: Optimize JSON files
### What each JSON file is used for
| File | Package | Purpose |
|------|---------|---------|
| `webhooks.json` | languageservice | Autocomplete/validation for `github.event.*` expressions. Contains event payload schemas from GitHub's REST API. |
| `objects.json` | languageservice | Deduplicated parameter definitions shared across webhooks (reduces duplication in webhooks.json). |
| `workflow-v1.0.json` | workflow-parser | Workflow schema defining valid YAML structure (`jobs`, `steps`, `runs-on`, event triggers, etc.). |
| `descriptions.json` | languageservice | Hover descriptions for contexts (`github`, `env`, `secrets`) and built-in functions (`format`, `contains`, etc.). |
| `schedule.json` | languageservice | Sample `github.event` payload for `on: schedule` trigger (not a real webhook, manually authored). |
| `workflow_call.json` | languageservice | Sample `github.event` payload for `on: workflow_call` trigger (not a real webhook, manually authored). |
### Impact table
| File | Original | Strip | Drop | Minify | Gzip | All (no Gzip) | All (w/ Gzip) |
|------|----------|-------|------|--------|------|---------------|---------------|
| `webhooks.json` | 6.2 MB | 5.6 MB | 5.0 MB | 2.4 MB | 188 KB | **1.0 MB** | **50 KB** |
| `objects.json` | 948 KB | N/A | 770 KB | 460 KB | 36 KB | **180 KB** | **18 KB** |
| `workflow-v1.0.json` | 112 KB | N/A | N/A | 70 KB | 13 KB | **70 KB** | **12 KB** |
| `descriptions.json` | 18 KB | N/A | N/A | 17 KB | 3 KB | **17 KB** | **3 KB** |
| `schedule.json` | 5.7 KB | N/A | N/A | 5.1 KB | 1 KB | **5.1 KB** | **1 KB** |
| `workflow_call.json` | 7.3 KB | N/A | N/A | 6.5 KB | 1 KB | **6.5 KB** | **1 KB** |
| **Total** | **7.3 MB** | | | | **~240 KB** | **~1.3 MB** | **~85 KB** |
- **Strip** = Remove unused fields (`summary`, `availability`, `category`, `action`)
- **Drop** = Remove 31 non-trigger events (`installation`, `star`, `team`, etc.)
- **Minify** = Remove whitespace (`JSON.stringify(data)` instead of `JSON.stringify(data, null, 2)`)
- **Gzip** = Network transfer size (free - handled automatically by browser/server)
### 1a. Minify all JSON files
**Generated files** (`webhooks.json`, `objects.json`):
- Update `languageservice/script/webhooks/index.ts`
- These are generated via `npm run update-webhooks` from GitHub's REST API spec
- Use `JSON.stringify(data)` instead of `JSON.stringify(data, null, 2)`
**Hand-authored files** (`workflow-v1.0.json`, `descriptions.json`, `schedule.json`, `workflow_call.json`):
- Add minification step to build scripts
### 1b. Strip unused fields from webhooks.json
Remove before writing:
- `summary`
- `availability`
- `category`
- `action`
### 1c. Drop non-trigger events from webhooks.json
Keep only events that can trigger workflows ([docs](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows)). Drop 31 events:
```
code_scanning_alert, commit_comment, dependabot_alert, deploy_key,
github_app_authorization, installation, installation_repositories,
installation_target, marketplace_purchase, member, membership, meta,
org_block, organization, package, ping, projects_v2, projects_v2_item,
pull_request_review_thread, repository, repository_import,
repository_vulnerability_alert, secret_scanning_alert,
secret_scanning_alert_location, security_advisory, security_and_analysis,
sponsorship, star, team, team_add, workflow_job
```
**Expected result:** Total JSON 7.3 MB → ~1.3 MB (82% reduction)
---
## Phase 2: Lazy loading (optional)
Refactor `eventPayloads.ts` to load JSON on first use:
```typescript
let webhooksData: Webhooks | null = null;
async function getWebhooks() {
if (!webhooksData) {
const { default: data } = await import("./webhooks.json");
webhooksData = hydrate(data);
}
return webhooksData;
}
```
**Benefit:** Faster initial load when `github.event.*` isn't used.
---
## Current github-ui architecture
github-ui lazy-loads the language service via dynamic import:
```typescript
// workflow-editor-next.ts
let languageServicePromise: Promise<typeof import('./workflow-editor-language-service')> | null = null
async function getLanguageServiceModule() {
if (!languageServicePromise) {
languageServicePromise = import('./workflow-editor-language-service')
}
return languageServicePromise
}
```
**What this means:**
- The language service is only loaded when the workflow editor needs autocomplete/hover/validation
- Webpack code-splits it into a separate chunk
- The ~7.9 MB package is NOT loaded on initial page load
**Why Phase 1 is the priority:**
- When the language service chunk IS loaded, it still loads all 7.3 MB of JSON
- Reducing JSON to ~1.3 MB directly reduces this chunk size
- No changes needed in github-ui - the benefit is automatic
---
## Not doing
- **Tree-shaking / `sideEffects`** - github-ui imports `complete`, `hover`, and `validate` together, and all three depend on the same webhook JSON. Tree-shaking can't eliminate any of it.
- **Replacing dependencies** - `yaml` and `cronstrue` are appropriately sized
- **Multi-pass validation API** - Too complex for the benefit
- **Further deduplication** - Current object deduplication is sufficient
---
## Future considerations
- **`workflow_call.json` may be incorrect** - For `on: workflow_call`, `github.event` is inherited from the calling workflow (could be push, pull_request, etc.). The current file shows generic properties which may be misleading for autocomplete. Consider returning `Null` for all modes or removing the file entirely.
---
## Success metrics
| Metric | Before | After |
|--------|--------|-------|
| `webhooks.json` | 6.2 MB | ~1.2 MB |
| `objects.json` | 948 KB | ~225 KB |
| Total package (disk) | 7.9 MB | ~1.5 MB |
| npm tarball (gzipped) | 368 KB | ~80 KB |
+35
View File
@@ -0,0 +1,35 @@
# JSON Optimization Summary
| File | Original | Strip | Minify | Gzip | Strip+Minify | Minify+Gzip | Strip+Minify+Gzip |
|------|----------|-------|--------|------|--------------|-------------|-------------------|
| `webhooks.json` | 4.1 MB | 3.7 MB | 1.6 MB | 188 KB | 1.4 MB | 84 KB | 68 KB |
| `objects.json` | 666 KB | N/A | 325 KB | 36 KB | 325 KB | 22 KB | 22 KB |
| **Total** | **4.78 MB** | - | **1.95 MB** | **224 KB** | **1.77 MB** | **106 KB** | **91 KB** |
**Stripping removes:** `summary`, `availability`, `category`, `action` fields from webhooks.json (unused by language service)
## workflow-v1.0.json (hand-authored schema)
| File | Original | Minify | Gzip | Minify+Gzip |
|------|----------|--------|------|-------------|
| `workflow-v1.0.json` | 91 KB | 69 KB | 13 KB | 12 KB |
**Note:** No stripping applicable - this is a hand-authored schema where all fields are used.
## Recommended Action
**For webhooks.json and objects.json:** Strip + Minify
- Modify `languageservice/script/webhooks/index.ts` to:
1. Strip unused fields (`summary`, `availability`, `category`, `action`) before writing
2. Use `JSON.stringify(obj)` instead of `JSON.stringify(obj, null, 2)` to minify
- Gzip is handled automatically by github-ui's production server
**For workflow-v1.0.json:** Minify at build time
- Add a build step to minify the JSON before publishing
**Expected savings:**
- npm package size: 4.78 MB → 1.77 MB (63% reduction)
- Network transfer (gzip): 224 KB → 91 KB (59% reduction)
-4
View File
@@ -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 - [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
- [browser-playground](./browser-playground) - Browser-based playground for the language service - [browser-playground](./browser-playground) - Browser-based playground for the language service
## Documentation
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
### Note ### Note
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions. Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
-319
View File
@@ -1,319 +0,0 @@
# ESM Migration Plan: Add File Extensions to Imports
## Overview
This document outlines the plan to migrate from TypeScript's deprecated `"moduleResolution": "node"` (node10) to `"moduleResolution": "node16"` or `"nodenext"`. This change is necessary because the published ESM packages have extensionless imports that don't work correctly in modern ESM environments.
## TL;DR - Remaining Work
- [x] expressions - Migrated ✅
- [x] workflow-parser - Migrated ✅
- [x] languageservice - Migrated ✅
- [x] languageserver - Add `.js` extensions to imports ✅
- [ ] languageserver - Update `tsconfig.build.json` to `moduleResolution: "node16"` (blocked by vscode-languageserver)
- [ ] languageserver - Upgrade `vscode-languageserver` to stable v10+ when released
**Blocker:** `[email protected]` lacks ESM exports. Stable v10 with `exports` field needed.
### ⚠️ Important: `skipLibCheck: true` Required
All migrated packages use `skipLibCheck: true` in their `tsconfig.build.json`. This works around a TS2386 "Overload signatures must all be optional or required" error in `@types/node/module.d.ts`.
**Why can't we just fix the error?** The error is in `@types/node`, a third-party package maintained by DefinitelyTyped. We can't modify `node_modules`, and upstream fixes take time.
**Is `skipLibCheck` safe?** Yes. It only skips type checking of `.d.ts` files (declaration files from dependencies). Our own `.ts` source files are still fully type-checked. This is a common and recommended workaround for issues in third-party type definitions.
---
## Issues Fixed
This migration will resolve the following issues:
- **#154** - Upgrade `moduleResolution` from `node` to `node16` or `nodenext` in tsconfig
- **#110** - Published ESM code has imports without file extensions
- **#64** - expressions: ERR_MODULE_NOT_FOUND attempting to run example demo script
- **#146** - Can not import `@actions/workflow-parser`
## Problem Statement
### Current State
All packages use `"moduleResolution": "node"`:
| Package | moduleResolution | TypeScript |
|---------|------------------|------------|
| expressions | `"node"` | ^4.7.4 |
| workflow-parser | `"node"` | ^4.8.4 |
| languageservice | `"node"` | ^4.8.4 |
| languageserver | `"node"` | ^4.8.4 |
| browser-playground | `"Node16"` ✅ | ^4.9.4 |
This causes TypeScript to emit code like:
```javascript
// Published to npm - INVALID ESM
export { Expr } from "./ast"; // Missing .js extension!
```
### Why This Fails
ESM in Node.js 12+ **requires** explicit file extensions. When users try to import these packages:
```javascript
// User's code
import { Expr } from "@actions/expressions";
```
Node.js fails with:
```
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/@actions/expressions/dist/ast'
```
## Migration Strategy
### Option A: TypeScript 5.7+ with `rewriteRelativeImportExtensions` (Recommended)
TypeScript 5.7 introduced a new compiler option that automatically rewrites `.ts` extensions to `.js` in output:
```jsonc
{
"compilerOptions": {
"moduleResolution": "node16", // or "nodenext"
"rewriteRelativeImportExtensions": true
}
}
```
**Source code:**
```typescript
import { Expr } from "./ast.ts";
```
**Compiled output:**
```javascript
export { Expr } from "./ast.js";
```
**Pros:**
- Source uses `.ts` extensions (matches actual files)
- Works with Deno (which requires `.ts` extensions)
- TypeScript automatically transforms to `.js`
- Modern, forward-looking approach
**Cons:**
- Requires TypeScript 5.7+
- Relatively new feature
- **BUG:** See "Known Issues" section below
### Option B: Manual `.js` Extensions
Use `.js` extensions in source TypeScript files:
```typescript
import { Expr } from "./ast.js"; // Points to .ts file, but use .js extension
```
**Pros:**
- Works with TypeScript 4.7+ (with node16 moduleResolution)
- Well-established pattern
- No post-processing needed
- Works with ts-jest without extra configuration
**Cons:**
- Confusing - `.js` files don't exist at write time
- Doesn't work with Deno out of the box
### Recommendation
**Use Option B** (manual `.js` extensions). Option A with `rewriteRelativeImportExtensions` has compatibility issues with ts-jest and requires additional workarounds.
---
## Known Issues and Workarounds (December 2025)
### 1. TypeScript Version Conflicts in Monorepo
**Problem:** The root `node_modules/typescript` was version 4.9.5 (pulled in by `ts-node` and `tsutils` dependencies), while workspace packages specified `^5.8.3`.
**Symptoms:**
- `npx tsc --version` showed 4.9.5
- `require('typescript').version` in ts-jest showed 5.8.3
- Confusing build failures
**Solution:** Add npm overrides in root `package.json`:
```json
{
"overrides": {
"typescript": "5.8.3"
}
}
```
### 2. ts-jest Compatibility with TypeScript 5.9+
**Problem:** ts-jest 29.4.6 uses `typescript.JSDocParsingMode.ParseAll` which doesn't exist in TypeScript's ES module exports.
**Error:**
```
TypeError: Cannot read properties of undefined (reading 'ParseAll')
at Object.<anonymous> (node_modules/ts-jest/dist/compiler/ts-compiler.js:43:123)
```
**Root Cause:** ts-jest accesses `typescript_1.default.JSDocParsingMode.ParseAll` but TypeScript has no default export in ESM.
**Solution:**
- Use ts-jest 29.0.3 (older version that doesn't use this API)
- OR wait for ts-jest fix
- **Stay on TypeScript 5.8.3, not 5.9+**
### 3. TypeScript `rewriteRelativeImportExtensions` Bug with .d.ts Files
**Problem:** TypeScript's `rewriteRelativeImportExtensions: true` correctly rewrites `.ts``.js` in `.js` output files, but **incorrectly keeps `.ts` extensions in `.d.ts` declaration files**.
**Example:**
- Source: `export { Expr } from "./ast.ts";`
- Output `index.js`: `export { Expr } from "./ast.js";` ✅ Correct
- Output `index.d.ts`: `export { Expr } from "./ast.ts";` ❌ Wrong (should be `.js`)
**Upstream Issue:** https://github.com/microsoft/TypeScript/issues/61037 (marked "Help Wanted", in Backlog, NOT FIXED as of Dec 2025)
**Workaround:** Post-process `.d.ts` files with a script. See `script/fix-dts-extensions.cjs`.
**Note:** Since we use Option B (manual `.js` extensions), this bug does not affect our migration.
### 4. yaml Package Internal Types Not Exported
**Problem:** The `yaml` package does not export internal types like `LinePos` and `NodeBase` that are used in `workflow-parser/src/workflows/yaml-object-reader.ts`.
**Error:**
```
error TS2305: Module '"yaml"' has no exported member 'LinePos'.
error TS2305: Module '"yaml"' has no exported member 'NodeBase'.
```
**Solution:** Define local type aliases in the file that uses them:
```typescript
// Local type definitions to replace yaml internal imports
type LinePos = { line: number; col: number };
type NodeBase = { range?: [number, number, number] };
```
### 5. languageserver Blocked by vscode-languageserver Dependency
**Problem:** The `vscode-languageserver` package (v8.0.2) does not have proper ESM exports. When using `moduleResolution: "node16"`, TypeScript requires packages to have an `exports` field in `package.json` for subpath imports to work.
**Error:**
```
src/index.ts(6,8): error TS2307: Cannot find module 'vscode-languageserver/browser' or its corresponding type declarations.
src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver/node' or its corresponding type declarations.
```
**Root Cause:** The `vscode-languageserver` package.json only has `main` and `browser` fields, but no `exports` field:
```json
{
"main": "./lib/node/main.js",
"browser": {
"./lib/node/main.js": "./lib/browser/main.js"
}
// No "exports" field!
}
```
With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rules which require explicit `exports` for subpath imports like `vscode-languageserver/browser` and `vscode-languageserver/node`.
**Status:** Partial - `.js` extensions added, waiting for stable `vscode-languageserver` release with ESM exports to complete migration.
**Completed:** All relative imports in languageserver source files have been updated to use `.js` extensions. This is compatible with the current `moduleResolution: "node"` and will enable a seamless migration once a stable vscode-languageserver version with ESM exports is available.
**Options to resolve:**
- Wait for stable vscode-languageserver v10+ with ESM exports
- Use pre-release `[email protected]` (has proper exports but is unstable)
- Fork or patch the dependency
---
## Migration Status
| Package | Tests | ESM Status |
|---------|-------|------------|
| expressions | 1068 | ✅ Migrated |
| workflow-parser | 292 | ✅ Migrated |
| languageservice | 452 | ✅ Migrated |
| languageserver | 31 | 🔶 Partial (`.js` extensions added, awaiting stable vscode-languageserver) |
---
## Required Configuration Changes
### tsconfig.build.json (each migrated package)
**Note:** We use **Option B** (manual `.js` extensions in source files) rather than `rewriteRelativeImportExtensions` because Option A caused ts-jest compatibility issues (tests would hang indefinitely).
```json
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"skipLibCheck": true,
"lib": ["ES2022"],
"target": "ES2022"
}
}
```
The `skipLibCheck: true` is needed to work around @types/node compatibility issues with TypeScript 5.x (TS2386 overload signature errors).
```
### jest.config.js (each migrated package)
```javascript
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: "ts-jest/presets/default-esm",
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
"^(\\.{1,2}/.*)\\.ts$": "$1",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
isolatedModules: true,
},
],
},
moduleFileExtensions: ["ts", "js"],
};
```
### Root package.json
```json
{
"overrides": {
"typescript": "5.8.3"
}
}
```
### Each workspace package.json
```json
{
"devDependencies": {
"typescript": "^5.8.3",
"ts-jest": "^29.0.3"
}
}
```
---
## References
- [TypeScript moduleResolution reference](https://www.typescriptlang.org/docs/handbook/modules/reference.html)
- [TypeScript 5.7 rewriteRelativeImportExtensions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-7.html#path-rewriting-for-relative-paths)
- [TypeScript .d.ts extension bug #61037](https://github.com/microsoft/TypeScript/issues/61037)
- [Node.js ESM mandatory extensions](https://nodejs.org/api/esm.html#mandatory-file-extensions)
- [ts-jest ESM support](https://kulshekhar.github.io/ts-jest/docs/guides/esm-support)
- [Community fork that works](https://github.com/boxbuild-io/actions-languageservices/commit/077fb2b58dfd2cca3d6e3df1fdf9e26e75db24ae)
-197
View File
@@ -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`)
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/expressions", "name": "@actions/expressions",
"version": "0.3.54", "version": "0.3.22",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"source": "./src/index.ts", "source": "./src/index.ts",
@@ -36,7 +36,7 @@
"clean": "rimraf dist", "clean": "rimraf dist",
"format": "prettier --write '**/*.ts'", "format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'", "format-check": "prettier --check '**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'", "lint-fix": "eslint --fix 'src/**/*.ts'",
"prepublishOnly": "npm run build && npm run test", "prepublishOnly": "npm run build && npm run test",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest", "test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
@@ -44,7 +44,7 @@
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"engines": { "engines": {
"node": ">= 20" "node": ">= 16.15"
}, },
"files": [ "files": [
"dist/**/*" "dist/**/*"
@@ -60,6 +60,6 @@
"prettier": "^2.8.3", "prettier": "^2.8.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ts-jest": "^29.0.3", "ts-jest": "^29.0.3",
"typescript": "^5.8.3" "typescript": "^4.7.4"
} }
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData} from "./data/index.js"; import {ExpressionData} from "./data";
import {Token} from "./lexer.js"; import {Token} from "./lexer";
export interface ExprVisitor<R> { export interface ExprVisitor<R> {
visitLiteral(literal: Literal): R; visitLiteral(literal: Literal): R;
+8 -8
View File
@@ -1,11 +1,11 @@
import {complete, CompletionItem, trimTokenVector} from "./completion.js"; import {complete, CompletionItem, trimTokenVector} from "./completion";
import {DescriptionDictionary} from "./completion/descriptionDictionary.js"; import {DescriptionDictionary} from "./completion/descriptionDictionary";
import {BooleanData} from "./data/boolean.js"; import {BooleanData} from "./data/boolean";
import {Dictionary} from "./data/dictionary.js"; import {Dictionary} from "./data/dictionary";
import {StringData} from "./data/string.js"; import {StringData} from "./data/string";
import {wellKnownFunctions} from "./funcs.js"; import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js"; import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {Lexer, TokenType} from "./lexer.js"; import {Lexer, TokenType} from "./lexer";
const testContext = new Dictionary( const testContext = new Dictionary(
{ {
+11 -19
View File
@@ -1,12 +1,11 @@
import {DescriptionPair} from "./completion/descriptionDictionary.js"; import {DescriptionPair} from "./completion/descriptionDictionary";
import {Dictionary, isDictionary} from "./data/dictionary.js"; import {Dictionary, isDictionary} from "./data/dictionary";
import {ExpressionData} from "./data/expressiondata.js"; import {ExpressionData} from "./data/expressiondata";
import {Evaluator} from "./evaluator.js"; import {Evaluator} from "./evaluator";
import {FeatureFlags} from "./features.js"; import {wellKnownFunctions} from "./funcs";
import {wellKnownFunctions} from "./funcs.js"; import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js"; import {Lexer, Token, TokenType} from "./lexer";
import {Lexer, Token, TokenType} from "./lexer.js"; import {Parser} from "./parser";
import {Parser} from "./parser.js";
export type CompletionItem = { export type CompletionItem = {
label: string; label: string;
@@ -27,15 +26,13 @@ export type CompletionItem = {
* @param context Context available for the expression * @param context Context available for the expression
* @param extensionFunctions List of functions available * @param extensionFunctions List of functions available
* @param functions Optional map of functions to use during evaluation * @param functions Optional map of functions to use during evaluation
* @param featureFlags Optional feature flags to control which features are enabled
* @returns Array of completion items * @returns Array of completion items
*/ */
export function complete( export function complete(
input: string, input: string,
context: Dictionary, context: Dictionary,
extensionFunctions: FunctionInfo[], extensionFunctions: FunctionInfo[],
functions?: Map<string, FunctionDefinition>, functions?: Map<string, FunctionDefinition>
featureFlags?: FeatureFlags
): CompletionItem[] { ): CompletionItem[] {
// Lex // Lex
const lexer = new Lexer(input); const lexer = new Lexer(input);
@@ -66,7 +63,7 @@ export function complete(
const result = contextKeys(context); const result = contextKeys(context);
// Merge with functions // Merge with functions
result.push(...functionItems(extensionFunctions, featureFlags)); result.push(...functionItems(extensionFunctions));
return result; return result;
} }
@@ -91,15 +88,10 @@ export function complete(
return contextKeys(result); return contextKeys(result);
} }
function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): CompletionItem[] { function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] {
const result: CompletionItem[] = []; const result: CompletionItem[] = [];
const flags = featureFlags ?? new FeatureFlags();
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) { for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
// Filter out case function if feature is disabled
if (fdef.name === "case" && !flags.isEnabled("allowCaseFunction")) {
continue;
}
result.push({ result.push({
label: fdef.name, label: fdef.name,
description: fdef.description, description: fdef.description,
@@ -1,5 +1,5 @@
import {StringData} from "../data/index.js"; import {StringData} from "../data";
import {DescriptionDictionary} from "./descriptionDictionary.js"; import {DescriptionDictionary} from "./descriptionDictionary";
describe("description dictionary", () => { describe("description dictionary", () => {
it("pairs contains all values", () => { it("pairs contains all values", () => {
@@ -1,5 +1,5 @@
import {Dictionary} from "../data/dictionary.js"; import {Dictionary} from "../data/dictionary";
import {ExpressionData, Kind, Pair} from "../data/expressiondata.js"; import {ExpressionData, Kind, Pair} from "../data/expressiondata";
export type DescriptionPair = Pair & {description?: string}; export type DescriptionPair = Pair & {description?: string};
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata.js"; import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata";
export class Array implements ExpressionDataInterface { export class Array implements ExpressionDataInterface {
private v: ExpressionData[] = []; private v: ExpressionData[] = [];
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js"; import {ExpressionDataInterface, Kind} from "./expressiondata";
export class BooleanData implements ExpressionDataInterface { export class BooleanData implements ExpressionDataInterface {
constructor(public readonly value: boolean) {} constructor(public readonly value: boolean) {}
+2 -2
View File
@@ -1,5 +1,5 @@
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {StringData} from "./string.js"; import {StringData} from "./string";
describe("dictionary", () => { describe("dictionary", () => {
it("pairs contains all values", () => { it("pairs contains all values", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata.js"; import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata";
export class Dictionary implements ExpressionDataInterface { export class Dictionary implements ExpressionDataInterface {
private keys: string[] = []; private keys: string[] = [];
+6 -6
View File
@@ -1,9 +1,9 @@
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {Null} from "./null.js"; import {Null} from "./null";
import {Array} from "./array.js"; import {Array} from "./array";
import {StringData} from "./string.js"; import {StringData} from "./string";
import {NumberData} from "./number.js"; import {NumberData} from "./number";
import {BooleanData} from "./boolean.js"; import {BooleanData} from "./boolean";
export enum Kind { export enum Kind {
String = 0, String = 0,
+9 -9
View File
@@ -1,9 +1,9 @@
export {Array} from "./array.js"; export {Array} from "./array";
export {BooleanData} from "./boolean.js"; export {BooleanData} from "./boolean";
export {Dictionary} from "./dictionary.js"; export {Dictionary} from "./dictionary";
export {ExpressionData, Kind} from "./expressiondata.js"; export {ExpressionData, Kind} from "./expressiondata";
export {Null} from "./null.js"; export {Null} from "./null";
export {NumberData} from "./number.js"; export {NumberData} from "./number";
export {replacer} from "./replacer.js"; export {replacer} from "./replacer";
export {reviver} from "./reviver.js"; export {reviver} from "./reviver";
export {StringData} from "./string.js"; export {StringData} from "./string";
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js"; import {ExpressionDataInterface, Kind} from "./expressiondata";
export class Null implements ExpressionDataInterface { export class Null implements ExpressionDataInterface {
public readonly kind = Kind.Null; public readonly kind = Kind.Null;
+1 -1
View File
@@ -1,4 +1,4 @@
import {NumberData} from "./number.js"; import {NumberData} from "./number";
describe("number", () => { describe("number", () => {
it("coerces to string", () => { it("coerces to string", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js"; import {ExpressionDataInterface, Kind} from "./expressiondata";
export class NumberData implements ExpressionDataInterface { export class NumberData implements ExpressionDataInterface {
constructor(public readonly value: number) {} constructor(public readonly value: number) {}
+6 -6
View File
@@ -1,9 +1,9 @@
import {Array} from "./array.js"; import {Array} from "./array";
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {Null} from "./null.js"; import {Null} from "./null";
import {NumberData} from "./number.js"; import {NumberData} from "./number";
import {replacer} from "./replacer.js"; import {replacer} from "./replacer";
import {StringData} from "./string.js"; import {StringData} from "./string";
describe("replacer", () => { describe("replacer", () => {
it("null", () => { it("null", () => {
+6 -6
View File
@@ -1,9 +1,9 @@
import {Array} from "./array.js"; import {Array} from "./array";
import {BooleanData} from "./boolean.js"; import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {Null} from "./null.js"; import {Null} from "./null";
import {NumberData} from "./number.js"; import {NumberData} from "./number";
import {StringData} from "./string.js"; import {StringData} from "./string";
/** /**
* Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON * Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON
+8 -8
View File
@@ -1,11 +1,11 @@
import {Array} from "./array.js"; import {Array} from "./array";
import {BooleanData} from "./boolean.js"; import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {ExpressionData} from "./expressiondata.js"; import {ExpressionData} from "./expressiondata";
import {Null} from "./null.js"; import {Null} from "./null";
import {NumberData} from "./number.js"; import {NumberData} from "./number";
import {reviver} from "./reviver.js"; import {reviver} from "./reviver";
import {StringData} from "./string.js"; import {StringData} from "./string";
describe("reviver", () => { describe("reviver", () => {
const tests: { const tests: {
+7 -7
View File
@@ -1,10 +1,10 @@
import {Array as dArray} from "./array.js"; import {Array as dArray} from "./array";
import {BooleanData} from "./boolean.js"; import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {ExpressionData} from "./expressiondata.js"; import {ExpressionData} from "./expressiondata";
import {Null} from "./null.js"; import {Null} from "./null";
import {NumberData} from "./number.js"; import {NumberData} from "./number";
import {StringData} from "./string.js"; import {StringData} from "./string";
/** /**
* Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object. * Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object.
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js"; import {ExpressionDataInterface, Kind} from "./expressiondata";
export class StringData implements ExpressionDataInterface { export class StringData implements ExpressionDataInterface {
constructor(public readonly value: string) {} constructor(public readonly value: string) {}
+1 -4
View File
@@ -1,4 +1,4 @@
import {Pos, Token, tokenString} from "./lexer.js"; import {Pos, Token, tokenString} from "./lexer";
export const MAX_PARSER_DEPTH = 50; export const MAX_PARSER_DEPTH = 50;
export const MAX_EXPRESSION_LENGTH = 21000; export const MAX_EXPRESSION_LENGTH = 21000;
@@ -12,7 +12,6 @@ export enum ErrorType {
ErrorExceededMaxLength, ErrorExceededMaxLength,
ErrorTooFewParameters, ErrorTooFewParameters,
ErrorTooManyParameters, ErrorTooManyParameters,
ErrorEvenParameters,
ErrorUnrecognizedContext, ErrorUnrecognizedContext,
ErrorUnrecognizedFunction ErrorUnrecognizedFunction
} }
@@ -43,8 +42,6 @@ function errorDescription(typ: ErrorType): string {
return "Too few parameters supplied"; return "Too few parameters supplied";
case ErrorType.ErrorTooManyParameters: case ErrorType.ErrorTooManyParameters:
return "Too many parameters supplied"; return "Too many parameters supplied";
case ErrorType.ErrorEvenParameters:
return "Even number of parameters supplied, requires an odd number of parameters";
case ErrorType.ErrorUnrecognizedContext: case ErrorType.ErrorUnrecognizedContext:
return "Unrecognized named-value"; return "Unrecognized named-value";
case ErrorType.ErrorUnrecognizedFunction: case ErrorType.ErrorUnrecognizedFunction:
+5 -5
View File
@@ -1,8 +1,8 @@
import * as data from "./data/index.js"; import * as data from "./data";
import {ExpressionEvaluationError} from "./errors.js"; import {ExpressionEvaluationError} from "./errors";
import {Evaluator} from "./evaluator.js"; import {Evaluator} from "./evaluator";
import {Lexer} from "./lexer.js"; import {Lexer} from "./lexer";
import {Parser} from "./parser.js"; import {Parser} from "./parser";
describe("evaluator", () => { describe("evaluator", () => {
const lexAndParse = (input: string) => { const lexAndParse = (input: string) => {
+8 -8
View File
@@ -10,14 +10,14 @@ import {
Logical, Logical,
Star, Star,
Unary Unary
} from "./ast.js"; } from "./ast";
import * as data from "./data/index.js"; import * as data from "./data";
import {FilteredArray} from "./filtered_array.js"; import {FilteredArray} from "./filtered_array";
import {wellKnownFunctions} from "./funcs.js"; import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition} from "./funcs/info.js"; import {FunctionDefinition} from "./funcs/info";
import {idxHelper} from "./idxHelper.js"; import {idxHelper} from "./idxHelper";
import {TokenType} from "./lexer.js"; import {TokenType} from "./lexer";
import {equals, falsy, greaterThan, lessThan, truthy} from "./result.js"; import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
export class Evaluator implements ExprVisitor<data.ExpressionData> { export class Evaluator implements ExprVisitor<data.ExpressionData> {
/** /**
-64
View File
@@ -1,64 +0,0 @@
import {FeatureFlags} from "./features.js";
describe("FeatureFlags", () => {
describe("isEnabled", () => {
it("returns false by default when no options provided", () => {
const flags = new FeatureFlags();
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns false by default when empty options provided", () => {
const flags = new FeatureFlags({});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns true when feature is explicitly enabled", () => {
const flags = new FeatureFlags({missingInputsQuickfix: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
it("returns false when feature is explicitly disabled", () => {
const flags = new FeatureFlags({missingInputsQuickfix: false});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns true when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
expect(flags.isEnabled("allowConcurrencyQueue")).toBe(true);
});
it("explicit feature flag takes precedence over all:true", () => {
const flags = new FeatureFlags({all: true, missingInputsQuickfix: false});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("explicit feature flag takes precedence over all:false", () => {
const flags = new FeatureFlags({all: false, missingInputsQuickfix: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
});
describe("getEnabledFeatures", () => {
it("returns empty array when no features enabled", () => {
const flags = new FeatureFlags();
expect(flags.getEnabledFeatures()).toEqual([]);
});
it("returns enabled features", () => {
const flags = new FeatureFlags({missingInputsQuickfix: true});
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
});
it("returns all features when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.getEnabledFeatures()).toEqual([
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCopilotRequestsPermission",
"allowConcurrencyQueue"
]);
});
});
});
-97
View File
@@ -1,97 +0,0 @@
/**
* Experimental feature flags.
*
* Individual feature flags take precedence over `all`.
* Example: { all: true, missingInputsQuickfix: false } enables all
* experimental features EXCEPT missingInputsQuickfix.
*
* When a feature graduates to stable, its flag becomes a no-op
* (the feature will be enabled regardless of the configuration value).
*/
export interface ExperimentalFeatures {
/**
* Enable all experimental features.
* Individual feature flags take precedence over this setting.
* @default false
*/
all?: boolean;
/**
* Enable quickfix code action for missing required action inputs.
* @default false
*/
missingInputsQuickfix?: boolean;
/**
* Warn when block scalars (| or >) use implicit clip chomping,
* which adds a trailing newline that may be unintentional.
* @default false
*/
blockScalarChompingWarning?: boolean;
/**
* Enable the case() function in expressions.
* @default false
*/
allowCaseFunction?: boolean;
/**
* Enable the copilot-requests permission in workflow permissions.
* @default false
*/
allowCopilotRequestsPermission?: boolean;
/**
* Enable the queue property in workflow concurrency settings.
* @default false
*/
allowConcurrencyQueue?: boolean;
}
/**
* Keys of ExperimentalFeatures that represent actual features (excludes 'all')
*/
export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
/**
* All known experimental feature keys.
* This list must be kept in sync with the ExperimentalFeatures interface.
*/
const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix",
"blockScalarChompingWarning",
"allowCaseFunction",
"allowCopilotRequestsPermission",
"allowConcurrencyQueue"
];
export class FeatureFlags {
private readonly features: ExperimentalFeatures;
constructor(features?: ExperimentalFeatures) {
this.features = features ?? {};
}
/**
* Check if an experimental feature is enabled.
*
* Resolution order:
* 1. Explicit feature flag (if set)
* 2. `all` flag (if set)
* 3. false (default)
*/
isEnabled(feature: ExperimentalFeatureKey): boolean {
const explicit = this.features[feature];
if (explicit !== undefined) {
return explicit;
}
return this.features.all ?? false;
}
/**
* Returns list of all enabled experimental features.
*/
getEnabledFeatures(): ExperimentalFeatureKey[] {
return allFeatureKeys.filter(key => this.isEnabled(key));
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
import * as data from "./data/index.js"; import * as data from "./data";
export class FilteredArray extends data.Array {} export class FilteredArray extends data.Array {}
+10 -17
View File
@@ -1,14 +1,13 @@
import {ErrorType, ExpressionError} from "./errors.js"; import {ErrorType, ExpressionError} from "./errors";
import {caseFunc} from "./funcs/case.js"; import {contains} from "./funcs/contains";
import {contains} from "./funcs/contains.js"; import {endswith} from "./funcs/endswith";
import {endswith} from "./funcs/endswith.js"; import {format} from "./funcs/format";
import {format} from "./funcs/format.js"; import {fromjson} from "./funcs/fromjson";
import {fromjson} from "./funcs/fromjson.js"; import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js"; import {join} from "./funcs/join";
import {join} from "./funcs/join.js"; import {startswith} from "./funcs/startswith";
import {startswith} from "./funcs/startswith.js"; import {tojson} from "./funcs/tojson";
import {tojson} from "./funcs/tojson.js"; import {Token} from "./lexer";
import {Token} from "./lexer.js";
export type ParseContext = { export type ParseContext = {
allowUnknownKeywords: boolean; allowUnknownKeywords: boolean;
@@ -17,7 +16,6 @@ export type ParseContext = {
}; };
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = { export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
case: caseFunc,
contains: contains, contains: contains,
endswith: endswith, endswith: endswith,
format: format, format: format,
@@ -55,9 +53,4 @@ export function validateFunction(context: ParseContext, identifier: Token, argCo
if (argCount > f.maxArgs) { if (argCount > f.maxArgs) {
throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier); throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier);
} }
// case function requires an odd number of arguments
if (name === "case" && argCount % 2 === 0) {
throw new ExpressionError(ErrorType.ErrorEvenParameters, identifier);
}
} }
-29
View File
@@ -1,29 +0,0 @@
import {ExpressionData, Kind} from "../data/index.js";
import {FunctionDefinition} from "./info.js";
export const caseFunc: FunctionDefinition = {
name: "case",
description:
"`case( pred1, val1, pred2, val2, ..., default )`\n\nEvaluates predicates in order and returns the value corresponding to the first predicate that evaluates to `true`. If no predicate matches, it returns the last argument as the default value.",
minArgs: 3,
maxArgs: Number.MAX_SAFE_INTEGER,
call: (...args: ExpressionData[]): ExpressionData => {
// Evaluate predicate-result pairs
for (let i = 0; i < args.length - 1; i += 2) {
const predicate = args[i];
// Predicate must be a boolean
if (predicate.kind !== Kind.Boolean) {
throw new Error("case predicate must evaluate to a boolean value");
}
// If predicate is true, return the corresponding result
if (predicate.value) {
return args[i + 1];
}
}
// No predicate matched, return default (last argument)
return args[args.length - 1];
}
};
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData, Kind} from "../data/index.js"; import {BooleanData, ExpressionData, Kind} from "../data";
import {equals} from "../result.js"; import {equals} from "../result";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const contains: FunctionDefinition = { export const contains: FunctionDefinition = {
name: "contains", name: "contains",
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData} from "../data/index.js"; import {BooleanData, ExpressionData} from "../data";
import {toUpperSpecial} from "../result.js"; import {toUpperSpecial} from "../result";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const endswith: FunctionDefinition = { export const endswith: FunctionDefinition = {
name: "endsWith", name: "endsWith",
+2 -2
View File
@@ -1,5 +1,5 @@
import {Null, NumberData, StringData} from "../data/index.js"; import {Null, NumberData, StringData} from "../data";
import {format} from "./format.js"; import {format} from "./format";
describe("format", () => { describe("format", () => {
it("null", () => { it("null", () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData, StringData} from "../data/index.js"; import {ExpressionData, StringData} from "../data";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const format: FunctionDefinition = { export const format: FunctionDefinition = {
name: "format", name: "format",
+4 -4
View File
@@ -1,7 +1,7 @@
import {ExpressionData} from "../data/index.js"; import {ExpressionData} from "../data";
import {reviver} from "../data/reviver.js"; import {reviver} from "../data/reviver";
import {ExpressionEvaluationError} from "../errors.js"; import {ExpressionEvaluationError} from "../errors";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const fromjson: FunctionDefinition = { export const fromjson: FunctionDefinition = {
name: "fromJson", name: "fromJson",
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData} from "../data/index.js"; import {ExpressionData} from "../data";
export interface FunctionInfo { export interface FunctionInfo {
name: string; name: string;
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData, Kind, StringData} from "../data/index.js"; import {ExpressionData, Kind, StringData} from "../data";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const join: FunctionDefinition = { export const join: FunctionDefinition = {
name: "join", name: "join",
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData} from "../data/index.js"; import {BooleanData, ExpressionData} from "../data";
import {toUpperSpecial} from "../result.js"; import {toUpperSpecial} from "../result";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const startswith: FunctionDefinition = { export const startswith: FunctionDefinition = {
name: "startsWith", name: "startsWith",
+3 -3
View File
@@ -1,6 +1,6 @@
import {ExpressionData, StringData} from "../data/index.js"; import {ExpressionData, StringData} from "../data";
import {replacer} from "../data/replacer.js"; import {replacer} from "../data/replacer";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const tojson: FunctionDefinition = { export const tojson: FunctionDefinition = {
name: "toJson", name: "toJson",
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData} from "./data/index.js"; import {ExpressionData} from "./data";
export class idxHelper { export class idxHelper {
public readonly str: string | undefined; public readonly str: string | undefined;
+9 -10
View File
@@ -1,10 +1,9 @@
export {Expr} from "./ast.js"; export {Expr} from "./ast";
export {complete, CompletionItem} from "./completion.js"; export {complete, CompletionItem} from "./completion";
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js"; export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
export * as data from "./data/index.js"; export * as data from "./data";
export {ExpressionError, ExpressionEvaluationError} from "./errors.js"; export {ExpressionError, ExpressionEvaluationError} from "./errors";
export {Evaluator} from "./evaluator.js"; export {Evaluator} from "./evaluator";
export {ExperimentalFeatureKey, ExperimentalFeatures, FeatureFlags} from "./features.js"; export {wellKnownFunctions} from "./funcs";
export {wellKnownFunctions} from "./funcs.js"; export {Lexer, Result} from "./lexer";
export {Lexer, Result} from "./lexer.js"; export {Parser} from "./parser";
export {Parser} from "./parser.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import {Lexer, Token, TokenType} from "./lexer.js"; import {Lexer, Token, TokenType} from "./lexer";
describe("lexer", () => { describe("lexer", () => {
const tests: { const tests: {
+2 -2
View File
@@ -1,5 +1,5 @@
import {StringData} from "./data/index.js"; import {StringData} from "./data";
import {MAX_EXPRESSION_LENGTH} from "./errors.js"; import {MAX_EXPRESSION_LENGTH} from "./errors";
export enum TokenType { export enum TokenType {
UNKNOWN, UNKNOWN,
+6 -17
View File
@@ -1,20 +1,9 @@
import { import {Binary, ContextAccess, Expr, FunctionCall, Grouping, IndexAccess, Literal, Logical, Star, Unary} from "./ast";
Binary, import * as data from "./data";
ContextAccess, import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors";
Expr, import {ParseContext, validateFunction} from "./funcs";
FunctionCall, import {FunctionInfo} from "./funcs/info";
Grouping, import {Token, TokenType} from "./lexer";
IndexAccess,
Literal,
Logical,
Star,
Unary
} from "./ast.js";
import * as data from "./data/index.js";
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors.js";
import {ParseContext, validateFunction} from "./funcs.js";
import {FunctionInfo} from "./funcs/info.js";
import {Token, TokenType} from "./lexer.js";
export class Parser { export class Parser {
private extContexts: Map<string, boolean>; private extContexts: Map<string, boolean>;
+2 -2
View File
@@ -1,5 +1,5 @@
import {BooleanData, ExpressionData, NumberData, StringData} from "./data/index.js"; import {BooleanData, ExpressionData, NumberData, StringData} from "./data";
import {coerceTypes, toUpperSpecial} from "./result.js"; import {coerceTypes, toUpperSpecial} from "./result";
describe("coerceTypes", () => { describe("coerceTypes", () => {
const tests: { const tests: {
+1 -1
View File
@@ -1,4 +1,4 @@
import * as data from "./data/index.js"; import * as data from "./data";
export function falsy(d: data.ExpressionData): boolean { export function falsy(d: data.ExpressionData): boolean {
switch (d.kind) { switch (d.kind) {
+9 -9
View File
@@ -1,14 +1,14 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import {Expr} from "./ast.js"; import {Expr} from "./ast";
import * as data from "./data/index.js"; import * as data from "./data";
import {kindStr} from "./data/expressiondata.js"; import {kindStr} from "./data/expressiondata";
import {replacer} from "./data/replacer.js"; import {replacer} from "./data/replacer";
import {reviver} from "./data/reviver.js"; import {reviver} from "./data/reviver";
import {ExpressionError} from "./errors.js"; import {ExpressionError} from "./errors";
import {Evaluator} from "./evaluator.js"; import {Evaluator} from "./evaluator";
import {Lexer, Result} from "./lexer.js"; import {Lexer, Result} from "./lexer";
import {Parser} from "./parser.js"; import {Parser} from "./parser";
interface TestResult { interface TestResult {
value: data.ExpressionData; value: data.ExpressionData;
-157
View File
@@ -1,157 +0,0 @@
{
"case": [
{
"expr": "case(true, 'first', 'default')",
"result": { "kind": "String", "value": "first" }
},
{
"expr": "case(false, 'first', 'default')",
"result": { "kind": "String", "value": "default" }
},
{
"expr": "case(true, 'first', false, 'second', 'default')",
"result": { "kind": "String", "value": "first" }
},
{
"expr": "case(false, 'first', true, 'second', 'default')",
"result": { "kind": "String", "value": "second" }
},
{
"expr": "case(false, 'first', false, 'second', 'default')",
"result": { "kind": "String", "value": "default" }
},
{
"expr": "case(1 == 1, 'equal', 'not equal')",
"result": { "kind": "String", "value": "equal" }
},
{
"expr": "case(1 == 2, 'equal', 'not equal')",
"result": { "kind": "String", "value": "not equal" }
},
{
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
"contexts": {
"github": {
"ref": "refs/heads/main",
"event_name": "push"
}
},
"result": { "kind": "String", "value": "main" }
},
{
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
"contexts": {
"github": {
"ref": "refs/heads/develop",
"event_name": "pull_request"
}
},
"result": { "kind": "String", "value": "pr" }
},
{
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
"contexts": {
"github": {
"ref": "refs/heads/develop",
"event_name": "push"
}
},
"result": { "kind": "String", "value": "other" }
},
{
"expr": "case(true, 123, 456)",
"result": { "kind": "Number", "value": 123 }
},
{
"expr": "case(false, 123, 456)",
"result": { "kind": "Number", "value": 456 }
},
{
"expr": "case(github.event == 'pull_request', 0, 1)",
"contexts": {
"github": {
"event": "pull_request"
}
},
"result": { "kind": "Number", "value": 0 }
},
{
"expr": "case(false, 0, 1)",
"result": { "kind": "Number", "value": 1 }
},
{
"expr": "case(true, false, true)",
"result": { "kind": "Boolean", "value": false }
},
{
"expr": "case(false, false, true)",
"result": { "kind": "Boolean", "value": true }
},
{
"expr": "case(true, '', 'default')",
"result": { "kind": "String", "value": "" }
},
{
"expr": "case(false, 'first', '')",
"result": { "kind": "String", "value": "" }
},
{
"expr": "case(true, fromJSON('[1,2,3]'), 'default')",
"result": { "kind": "Array", "value": [1, 2, 3] }
},
{
"expr": "case(true, fromJSON('{\"key\":\"value\"}'), 'default')",
"result": { "kind": "Object", "value": { "key": "value" } }
},
{
"expr": "case(false, 'first', false, 'second', false, 'third', false, 'fourth', 'default')",
"result": { "kind": "String", "value": "default" }
},
{
"expr": "case(false, 'first', false, 'second', true, 'third', false, 'fourth', 'default')",
"result": { "kind": "String", "value": "third" }
},
{
"expr": "case('not a boolean', 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(1, 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(null, 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(fromJSON('[]'), 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(fromJSON('{}'), 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(true, 'first', false, 'second')",
"err": {
"kind": "parsing",
"value": "Even number of parameters supplied, requires an odd number of parameters: 'case'. Located at position 1 within expression: case(true, 'first', false, 'second')"
}
}
]
}
+1 -4
View File
@@ -2,12 +2,9 @@
"exclude": ["./src/**/*.test.ts"], "exclude": ["./src/**/*.test.ts"],
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"noEmit": false, "noEmit": false,
"outDir": "./dist", "outDir": "./dist"
"skipLibCheck": true
} }
} }
-206
View File
@@ -10,14 +10,6 @@ The [package](https://www.npmjs.com/package/@actions/languageserver) contains Ty
npm install @actions/languageserver npm install @actions/languageserver
``` ```
To install the language server as a standalone CLI:
```bash
npm install -g @actions/languageserver
```
This makes the `actions-languageserver` command available globally.
## Usage ## Usage
### Basic usage using `vscode-languageserver-node` ### Basic usage using `vscode-languageserver-node`
@@ -84,11 +76,6 @@ export interface InitializationOptions {
* Desired log level * Desired log level
*/ */
logLevel?: LogLevel; logLevel?: LogLevel;
/**
* Experimental features that are opt-in
*/
experimentalFeatures?: ExperimentalFeatures;
} }
``` ```
@@ -105,178 +92,6 @@ const clientOptions: LanguageClientOptions = {
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions); const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
``` ```
### Experimental Features
The language server supports opt-in experimental features via the `experimentalFeatures` initialization option. These features may change or be removed in between releases.
```typescript
initializationOptions: {
experimentalFeatures: {
// Enable all experimental features
all: true,
// Or enable specific features
missingInputsQuickfix: true,
}
}
```
**Available experimental features:**
| Feature | Description |
|---------|-------------|
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
| `allowConcurrencyQueue` | Enable the `concurrency.queue` workflow property |
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
When a feature graduates to stable, its flag becomes a no-op and the feature will be enabled regardless of the configuration value.
### Standalone CLI
After installing globally, you can run the language server directly:
```bash
actions-languageserver --stdio
```
This starts the language server using stdio transport, which is the standard way for editors to communicate with language servers.
### In Neovim
#### 1. Install the language server
```bash
npm install -g @actions/languageserver
```
#### 2. Set up filetype detection
Add this to your `init.lua` to detect GitHub Actions workflow files:
```lua
vim.filetype.add({
pattern = {
[".*/%.github/workflows/.*%.ya?ml"] = "yaml.ghactions",
},
})
```
This sets the filetype to `yaml.ghactions` for YAML files in `.github/workflows/`, allowing you to keep separate YAML LSP configurations if needed.
#### 3. Create the LSP configuration
As of Neovim 0.11+ you can add this configuration in `~/.config/nvim/lsp/actionsls.lua`:
```lua
local function get_github_token()
local handle = io.popen("gh auth token 2>/dev/null")
if not handle then return nil end
local token = handle: read("*a"):gsub("%s+", "")
handle:close()
return token ~= "" and token or nil
end
local function parse_github_remote(url)
if not url or url == "" then return nil end
-- SSH format: [email protected]:owner/repo.git
local owner, repo = url:match("git@github%.com:([^/]+)/([^/%.]+)")
if owner and repo then
return owner, repo: gsub("%.git$", "")
end
-- HTTPS format: https://github.com/owner/repo.git
owner, repo = url:match("github%.com/([^/]+)/([^/%.]+)")
if owner and repo then
return owner, repo:gsub("%.git$", "")
end
return nil
end
local function get_repo_info(owner, repo)
local cmd = string.format(
"gh repo view %s/%s --json id,owner --template '{{.id}}\t{{.owner.type}}' 2>/dev/null",
owner,
repo
)
local handle = io.popen(cmd)
if not handle then return nil end
local result = handle: read("*a"):gsub("%s+$", "")
handle:close()
local id, owner_type = result:match("^(%d+)\t(.+)$")
if id then
return {
id = tonumber(id),
organizationOwned = owner_type == "Organization",
}
end
return nil
end
local function get_repos_config()
local handle = io.popen("git rev-parse --show-toplevel 2>/dev/null")
if not handle then return nil end
local git_root = handle: read("*a"):gsub("%s+", "")
handle:close()
if git_root == "" then return nil end
handle = io.popen("git remote get-url origin 2>/dev/null")
if not handle then return nil end
local remote_url = handle:read("*a"):gsub("%s+", "")
handle:close()
local owner, name = parse_github_remote(remote_url)
if not owner or not name then return nil end
local info = get_repo_info(owner, name)
return {
{
id = info and info.id or 0,
owner = owner,
name = name,
organizationOwned = info and info.organizationOwned or false,
workspaceUri = "file://" .. git_root,
},
}
end
return {
cmd = { "actions-languageserver", "--stdio" },
filetypes = { "yaml.ghactions" },
root_markers = { ".git" },
init_options = {
-- Optional: provide a GitHub token and repo context for added functionality
-- (e.g., repository-specific completions)
sessionToken = get_github_token(),
repos = get_repos_config(),
},
}
```
#### 4. Enable the LSP
Add to your `init.lua`:
```lua
vim.lsp.enable('actionsls')
```
#### 5. Verify it's working
Open any `.github/workflows/*.yml` file and run:
```vim
:checkhealth vim.lsp
```
You should see `actionsls` in the list of attached clients.
## Contributing ## Contributing
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations. See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
@@ -295,27 +110,6 @@ or to watch for changes
npm run watch npm run watch
``` ```
### Running the language server locally
After running
```bash
npm run build:cli
npm link
```
`actions-languageserver` will be available globally. You can start it with:
```bash
actions-languageserver --stdio
```
Once linked you can also watch for changes and rebuild automatically:
```bash
npm run watch:cli
```
### Test ### Test
```bash ```bash
@@ -1,2 +0,0 @@
#!/usr/bin/env node
import "../dist/cli.bundle.cjs";
+9 -17
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/languageserver", "name": "@actions/languageserver",
"version": "0.3.54", "version": "0.3.22",
"description": "Language server for GitHub Actions", "description": "Language server for GitHub Actions",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -31,25 +31,20 @@
"url": "https://github.com/actions/languageservices" "url": "https://github.com/actions/languageservices"
}, },
"scripts": { "scripts": {
"build": "tsc --build tsconfig.build.json && npm run build:cli", "build": "tsc --build tsconfig.build.json",
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
"clean": "rimraf dist", "clean": "rimraf dist",
"format": "prettier --write '**/*.ts'", "format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'", "format-check": "prettier --check '**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'", "lint-fix": "eslint --fix 'src/**/*.ts'",
"prepublishOnly": "npm run build && npm run test", "prepublishOnly": "npm run build && npm run test",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest", "test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch", "test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"watch": "tsc --build tsconfig.build.json --watch", "watch": "tsc --build tsconfig.build.json --watch"
"watch:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs --watch"
},
"bin": {
"actions-languageserver": "./bin/actions-languageserver"
}, },
"dependencies": { "dependencies": {
"@actions/languageservice": "^0.3.54", "@actions/languageservice": "^0.3.22",
"@actions/workflow-parser": "^0.3.54", "@actions/workflow-parser": "^0.3.22",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0", "@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2", "vscode-languageserver": "^8.0.2",
@@ -57,26 +52,23 @@
"yaml": "^2.1.3" "yaml": "^2.1.3"
}, },
"engines": { "engines": {
"node": ">= 20" "node": ">= 16.15"
}, },
"files": [ "files": [
"dist/**/*", "dist/**/*"
"bin/**/*"
], ],
"devDependencies": { "devDependencies": {
"@types/jest": "^29.0.3", "@types/jest": "^29.0.3",
"@typescript-eslint/eslint-plugin": "^5.56.0", "@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0", "@typescript-eslint/parser": "^5.56.0",
"esbuild": "^0.27.1",
"eslint": "^8.36.0", "eslint": "^8.36.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"fetch-mock": "^9.11.0", "fetch-mock": "^9.11.0",
"jest": "^29.0.3", "jest": "^29.0.3",
"node-fetch": "^2.6.7",
"prettier": "^2.8.3", "prettier": "^2.8.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ts-jest": "^29.0.3", "ts-jest": "^29.0.3",
"typescript": "^5.8.3" "typescript": "^4.8.4"
} }
} }
+15 -59
View File
@@ -1,18 +1,8 @@
import { import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
documentLinks,
getCodeActions,
getInlayHints,
hover,
validate,
ValidationConfig
} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log"; import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache"; import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import { import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem, CompletionItem,
Connection, Connection,
DocumentLink, DocumentLink,
@@ -22,27 +12,24 @@ import {
HoverParams, HoverParams,
InitializeParams, InitializeParams,
InitializeResult, InitializeResult,
InlayHint,
InlayHintParams,
TextDocumentIdentifier, TextDocumentIdentifier,
TextDocumentPositionParams, TextDocumentPositionParams,
TextDocuments, TextDocuments,
TextDocumentSyncKind TextDocumentSyncKind
} from "vscode-languageserver"; } from "vscode-languageserver";
import {TextDocument} from "vscode-languageserver-textdocument"; import {TextDocument} from "vscode-languageserver-textdocument";
import {getClient} from "./client.js"; import {getClient} from "./client";
import {Commands} from "./commands.js"; import {Commands} from "./commands";
import {contextProviders} from "./context-providers.js"; import {contextProviders} from "./context-providers";
import {descriptionProvider} from "./description-provider.js"; import {descriptionProvider} from "./description-provider";
import {FeatureFlags} from "@actions/expressions"; import {getFileProvider} from "./file-provider";
import {getFileProvider} from "./file-provider.js"; import {InitializationOptions, RepositoryContext} from "./initializationOptions";
import {InitializationOptions, RepositoryContext} from "./initializationOptions.js"; import {onCompletion} from "./on-completion";
import {onCompletion} from "./on-completion.js"; import {ReadFileRequest, Requests} from "./request";
import {ReadFileRequest, Requests} from "./request.js"; import {getActionsMetadataProvider} from "./utils/action-metadata";
import {getActionsMetadataProvider} from "./utils/action-metadata.js"; import {TTLCache} from "./utils/cache";
import {TTLCache} from "./utils/cache.js"; import {timeOperation} from "./utils/timer";
import {timeOperation} from "./utils/timer.js"; import {valueProviders} from "./value-providers";
import {valueProviders} from "./value-providers.js";
export function initConnection(connection: Connection) { export function initConnection(connection: Connection) {
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument); const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
@@ -52,7 +39,6 @@ export function initConnection(connection: Connection) {
const cache = new TTLCache(); const cache = new TTLCache();
let hasWorkspaceFolderCapability = false; let hasWorkspaceFolderCapability = false;
let featureFlags = new FeatureFlags();
// Register remote console logger with language service // Register remote console logger with language service
registerLogger(connection.console); registerLogger(connection.console);
@@ -76,8 +62,6 @@ export function initConnection(connection: Connection) {
setLogLevel(options.logLevel); setLogLevel(options.logLevel);
} }
featureFlags = new FeatureFlags(options.experimentalFeatures);
const result: InitializeResult = { const result: InitializeResult = {
capabilities: { capabilities: {
textDocumentSync: TextDocumentSyncKind.Full, textDocumentSync: TextDocumentSyncKind.Full,
@@ -88,10 +72,6 @@ export function initConnection(connection: Connection) {
hoverProvider: true, hoverProvider: true,
documentLinkProvider: { documentLinkProvider: {
resolveProvider: false resolveProvider: false
},
inlayHintProvider: true,
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
} }
} }
}; };
@@ -108,11 +88,6 @@ export function initConnection(connection: Connection) {
}); });
connection.onInitialized(() => { connection.onInitialized(() => {
const enabledFeatures = featureFlags.getEnabledFeatures();
if (enabledFeatures.length > 0) {
connection.console.info(`Experimental features enabled: ${enabledFeatures.join(", ")}`);
}
if (hasWorkspaceFolderCapability) { if (hasWorkspaceFolderCapability) {
connection.workspace.onDidChangeWorkspaceFolders(() => { connection.workspace.onDidChangeWorkspaceFolders(() => {
clearCache(); clearCache();
@@ -136,8 +111,7 @@ export function initConnection(connection: Connection) {
actionsMetadataProvider: getActionsMetadataProvider(client, cache), actionsMetadataProvider: getActionsMetadataProvider(client, cache),
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => { fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest); return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
}), })
featureFlags
}; };
const result = await validate(textDocument, config); const result = await validate(textDocument, config);
@@ -154,8 +128,7 @@ export function initConnection(connection: Connection) {
getDocument(documents, textDocument), getDocument(documents, textDocument),
client, client,
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)), repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
cache, cache
featureFlags
) )
); );
}); });
@@ -185,23 +158,6 @@ export function initConnection(connection: Connection) {
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri); return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
}); });
connection.languages.inlayHint.on(async ({textDocument}: InlayHintParams): Promise<InlayHint[] | null> => {
return timeOperation("inlayHints", () => {
return getInlayHints(getDocument(documents, textDocument));
});
});
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
const document = getDocument(documents, params.textDocument);
return getCodeActions({
uri: params.textDocument.uri,
documentContent: document.getText(),
diagnostics: params.context.diagnostics,
only: params.context.only,
featureFlags
});
});
// Make the text document manager listen on the connection // Make the text document manager listen on the connection
// for open, change and close text document events // for open, change and close text document events
documents.listen(connection); documents.listen(connection);
@@ -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.js";
import {RepositoryContext} from "./initializationOptions.js";
import {TTLCache} from "./utils/cache.js";
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);
});
});
});
+6 -17
View File
@@ -3,11 +3,11 @@ import {ContextProviderConfig} from "@actions/languageservice";
import {Mode} from "@actions/languageservice/context-providers/default"; import {Mode} from "@actions/languageservice/context-providers/default";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context"; import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {getSecrets} from "./context-providers/secrets.js"; import {getSecrets} from "./context-providers/secrets";
import {getStepsContext} from "./context-providers/steps.js"; import {getStepsContext} from "./context-providers/steps";
import {getVariables} from "./context-providers/variables.js"; import {getVariables} from "./context-providers/variables";
import {RepositoryContext} from "./initializationOptions.js"; import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache.js"; import {TTLCache} from "./utils/cache";
export function contextProviders( export function contextProviders(
client: Octokit | undefined, client: Octokit | undefined,
@@ -15,18 +15,7 @@ export function contextProviders(
cache: TTLCache cache: TTLCache
): ContextProviderConfig { ): ContextProviderConfig {
if (!repo || !client) { if (!repo || !client) {
// When GitHub client/repo is unavailable, return an incomplete dictionary return {getContext: () => Promise.resolve(undefined)};
// to avoid false "Context access might be invalid" warnings
return {
getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => {
if (name === "secrets" || name === "vars") {
const context = defaultContext || new DescriptionDictionary();
context.complete = false;
return Promise.resolve(context);
}
return Promise.resolve(undefined);
}
};
} }
const getContext = async ( const getContext = async (
@@ -1,7 +1,7 @@
import {ActionOutputs, ActionReference} from "@actions/languageservice/action"; import {ActionOutputs, ActionReference} from "@actions/languageservice/action";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata.js"; import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
export async function getActionOutputs( export async function getActionOutputs(
octokit: Octokit, octokit: Octokit,
@@ -6,10 +6,10 @@ import {warn} from "@actions/languageservice/log";
import {isMapping, isString} from "@actions/workflow-parser"; import {isMapping, isString} from "@actions/workflow-parser";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "../initializationOptions.js"; import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
import {errorStatus} from "../utils/error.js"; import {errorStatus} from "../utils/error";
import {getRepoPermission} from "../utils/repo-permission.js"; import {getRepoPermission} from "../utils/repo-permission";
export async function getSecrets( export async function getSecrets(
workflowContext: WorkflowContext, workflowContext: WorkflowContext,
@@ -49,7 +49,7 @@ export async function getSecrets(
if (isString(x.value)) { if (isString(x.value)) {
environmentName = x.value.value; environmentName = x.value.value;
} else { } else {
// this means we have a dynamic 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 // want to make sure we skip doing secret validation
secretsContext.complete = false; secretsContext.complete = false;
} }
@@ -1,11 +1,11 @@
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary} from "@actions/expressions";
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps"; import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import {createWorkflowContext} from "../test-utils/workflow-context.js"; import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
import {getStepsContext} from "./steps.js"; import {getStepsContext} from "./steps";
const workflow = ` const workflow = `
name: Caching Primes name: Caching Primes
@@ -63,47 +63,6 @@ it("returns default context when job is undefined", async () => {
expect(stepsContext).toEqual(defaultContext); expect(stepsContext).toEqual(defaultContext);
}); });
it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
const mock = fetchMock
.sandbox()
.getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata);
const workflowContext = await createWorkflowContext(workflow, "build");
const defaultContext = getDefaultStepsContext(workflowContext);
const stepsContext = await getStepsContext(
new Octokit({
request: {
fetch: mock
}
}),
new TTLCache(),
defaultContext,
workflowContext
);
// Get the step context
const stepContext = stepsContext?.get("cache-primes");
if (!stepContext) {
throw new Error("Expected stepContext to be defined");
}
expect(isDescriptionDictionary(stepContext)).toBe(true);
// Get the outputs - should be a dictionary, not null
const outputs = (stepContext as DescriptionDictionary).get("outputs");
if (!outputs) {
throw new Error("Expected outputs to be defined");
}
expect(isDescriptionDictionary(outputs)).toBe(true);
// Outputs should be marked incomplete to allow dynamic outputs
const outputsDict = outputs as DescriptionDictionary;
expect(outputsDict.complete).toBe(false);
// Known outputs from action.yml should be present
expect(outputsDict.get("cache-hit")).toBeDefined();
});
it("adds action outputs", async () => { it("adds action outputs", async () => {
const mock = fetchMock const mock = fetchMock
.sandbox() .sandbox()
@@ -124,22 +83,17 @@ it("adds action outputs", async () => {
); );
expect(stepsContext).toBeDefined(); expect(stepsContext).toBeDefined();
// Create expected outputs dict with complete = false
// (actions can have dynamic outputs beyond what's declared in action.yml)
const expectedOutputs = new DescriptionDictionary({
key: "cache-hit",
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
description: "A boolean value to indicate an exact match was found for the primary key"
});
expectedOutputs.complete = false;
expect(stepsContext).toEqual( expect(stepsContext).toEqual(
new DescriptionDictionary({ new DescriptionDictionary({
key: "cache-primes", key: "cache-primes",
value: new DescriptionDictionary( value: new DescriptionDictionary(
{ {
key: "outputs", key: "outputs",
value: 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", key: "conclusion",
@@ -3,8 +3,8 @@ import {parseActionReference} from "@actions/languageservice/action";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context"; import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {isActionStep} from "@actions/workflow-parser/model/type-guards"; import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
import {getActionOutputs} from "./action-outputs.js"; import {getActionOutputs} from "./action-outputs";
export async function getStepsContext( export async function getStepsContext(
octokit: Octokit, octokit: Octokit,
@@ -58,8 +58,6 @@ export async function getStepsContext(
continue; continue;
} }
const outputsDict = new DescriptionDictionary(); const outputsDict = new DescriptionDictionary();
// Actions can have dynamic outputs beyond what's declared in action.yml
outputsDict.complete = false;
for (const [key, value] of Object.entries(outputs)) { for (const [key, value] of Object.entries(outputs)) {
outputsDict.add(key, new data.StringData(value.description), value.description); outputsDict.add(key, new data.StringData(value.description), value.description);
} }
@@ -7,10 +7,10 @@ import {isMapping, isString} from "@actions/workflow-parser";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {RequestError} from "@octokit/request-error"; import {RequestError} from "@octokit/request-error";
import {RepositoryContext} from "../initializationOptions.js"; import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
import {errorStatus} from "../utils/error.js"; import {errorStatus} from "../utils/error";
import {getRepoPermission} from "../utils/repo-permission.js"; import {getRepoPermission} from "../utils/repo-permission";
export async function getVariables( export async function getVariables(
workflowContext: WorkflowContext, workflowContext: WorkflowContext,
@@ -26,8 +26,6 @@ export async function getVariables(
return secretsContext; return secretsContext;
} }
const variablesContext = defaultContext || new DescriptionDictionary();
let environmentName: string | undefined; let environmentName: string | undefined;
if (workflowContext?.job?.environment) { if (workflowContext?.job?.environment) {
if (isString(workflowContext.job.environment)) { if (isString(workflowContext.job.environment)) {
@@ -37,19 +35,14 @@ export async function getVariables(
if (isString(x.key) && x.key.value === "name") { if (isString(x.key) && x.key.value === "name") {
if (isString(x.value)) { if (isString(x.value)) {
environmentName = x.value.value; environmentName = x.value.value;
} else {
// this means we have a dynamic environment, in those situations we want to skip validation
variablesContext.complete = false;
} }
break; break;
} }
} }
} else {
// if the expression is something like environment: ${{ ... }} then we want to skip validation
variablesContext.complete = false;
} }
} }
const variablesContext = defaultContext || new DescriptionDictionary();
try { try {
const variables = await getRemoteVariables(octokit, cache, repo, environmentName); const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
+3 -3
View File
@@ -1,8 +1,8 @@
import {DescriptionProvider} from "@actions/languageservice/hover"; import {DescriptionProvider} from "@actions/languageservice/hover";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {getActionDescription} from "./description-providers/action-description.js"; import {getActionDescription} from "./description-providers/action-description";
import {getActionInputDescription} from "./description-providers/action-input.js"; import {getActionInputDescription} from "./description-providers/action-input";
import {TTLCache} from "./utils/cache.js"; import {TTLCache} from "./utils/cache";
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider { export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => { const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
@@ -1,9 +1,9 @@
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import {createWorkflowContext} from "../test-utils/workflow-context.js"; import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
import {getActionDescription} from "./action-description.js"; import {getActionDescription} from "./action-description";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js"; import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
const workflow = ` const workflow = `
name: Hello World name: Hello World
@@ -2,8 +2,8 @@ import {actionUrl, parseActionReference} from "@actions/languageservice/action";
import {isActionStep} from "@actions/workflow-parser/model/type-guards"; import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template"; import {Step} from "@actions/workflow-parser/model/workflow-template";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata.js"; import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> { export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> {
if (!isActionStep(step)) { if (!isActionStep(step)) {
@@ -2,10 +2,10 @@ import {StringToken} from "@actions/workflow-parser/templates/tokens/string-toke
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js"; import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
import {createWorkflowContext} from "../test-utils/workflow-context.js"; import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
import {getActionInputDescription} from "./action-input.js"; import {getActionInputDescription} from "./action-input";
const workflow = ` const workflow = `
name: Hello World name: Hello World
@@ -4,8 +4,8 @@ import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template"; import {Step} from "@actions/workflow-parser/model/workflow-template";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token"; import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata.js"; import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
export async function getActionInputDescription( export async function getActionInputDescription(
client: Octokit, client: Octokit,
+1 -1
View File
@@ -2,7 +2,7 @@ import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider"; import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference"; import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {TTLCache} from "./utils/cache.js"; import {TTLCache} from "./utils/cache";
import * as vscodeURI from "vscode-uri"; import * as vscodeURI from "vscode-uri";
export function getFileProvider( export function getFileProvider(
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from "vscode-languageserver/browser"; } from "vscode-languageserver/browser";
import {createConnection as createNodeConnection} from "vscode-languageserver/node"; import {createConnection as createNodeConnection} from "vscode-languageserver/node";
import {initConnection} from "./connection.js"; import {initConnection} from "./connection";
/** Helper function determining whether we are executing with node runtime */ /** Helper function determining whether we are executing with node runtime */
function isNode(): boolean { function isNode(): boolean {
@@ -1,4 +1,3 @@
import {ExperimentalFeatures} from "@actions/expressions";
import {LogLevel} from "@actions/languageservice/log"; import {LogLevel} from "@actions/languageservice/log";
export {LogLevel} from "@actions/languageservice/log"; export {LogLevel} from "@actions/languageservice/log";
@@ -29,12 +28,6 @@ export interface InitializationOptions {
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3" * If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
*/ */
gitHubApiUrl?: string; gitHubApiUrl?: string;
/**
* Experimental features that are opt-in.
* Features listed here may change or be removed without notice.
*/
experimentalFeatures?: ExperimentalFeatures;
} }
export interface RepositoryContext { export interface RepositoryContext {
+7 -10
View File
@@ -1,14 +1,13 @@
import {complete} from "@actions/languageservice/complete"; import {complete} from "@actions/languageservice/complete";
import type {FeatureFlags} from "@actions/expressions";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {CompletionItem, Connection, Position} from "vscode-languageserver"; import {CompletionItem, Connection, Position} from "vscode-languageserver";
import {TextDocument} from "vscode-languageserver-textdocument"; import {TextDocument} from "vscode-languageserver-textdocument";
import {contextProviders} from "./context-providers.js"; import {contextProviders} from "./context-providers";
import {getFileProvider} from "./file-provider.js"; import {getFileProvider} from "./file-provider";
import {RepositoryContext} from "./initializationOptions.js"; import {RepositoryContext} from "./initializationOptions";
import {Requests} from "./request.js"; import {Requests} from "./request";
import {TTLCache} from "./utils/cache.js"; import {TTLCache} from "./utils/cache";
import {valueProviders} from "./value-providers.js"; import {valueProviders} from "./value-providers";
export async function onCompletion( export async function onCompletion(
connection: Connection, connection: Connection,
@@ -16,13 +15,11 @@ export async function onCompletion(
document: TextDocument, document: TextDocument,
client: Octokit | undefined, client: Octokit | undefined,
repoContext: RepositoryContext | undefined, repoContext: RepositoryContext | undefined,
cache: TTLCache, cache: TTLCache
featureFlags?: FeatureFlags
): Promise<CompletionItem[]> { ): Promise<CompletionItem[]> {
return await complete(document, position, { return await complete(document, position, {
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache), valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache), contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
featureFlags,
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => { fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
return await connection.sendRequest(Requests.ReadFile, {path}); return await connection.sendRequest(Requests.ReadFile, {path});
}) })
@@ -1,7 +1,7 @@
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import {fetchActionMetadata} from "./action-metadata.js"; import {fetchActionMetadata} from "./action-metadata";
import {TTLCache} from "./cache.js"; import {TTLCache} from "./cache";
// A simplified version of the action.yml file from actions/checkout // A simplified version of the action.yml file from actions/checkout
const actionMetadataContent = ` const actionMetadataContent = `
+2 -2
View File
@@ -3,8 +3,8 @@ import {ActionsMetadataProvider} from "@actions/languageservice";
import {error} from "@actions/languageservice/log"; import {error} from "@actions/languageservice/log";
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest"; import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
import {parse} from "yaml"; import {parse} from "yaml";
import {TTLCache} from "./cache.js"; import {TTLCache} from "./cache";
import {errorMessage, errorStatus} from "./error.js"; import {errorMessage, errorStatus} from "./error";
export function getActionsMetadataProvider( export function getActionsMetadataProvider(
client: Octokit | undefined, client: Octokit | undefined,
+4 -4
View File
@@ -1,9 +1,9 @@
import {error} from "@actions/languageservice/log"; import {error} from "@actions/languageservice/log";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "../initializationOptions.js"; import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "./cache.js"; import {TTLCache} from "./cache";
import {errorStatus} from "./error.js"; import {errorStatus} from "./error";
import {getUsername} from "./username.js"; import {getUsername} from "./username";
export type RepoPermission = "admin" | "write" | "read" | "none"; export type RepoPermission = "admin" | "write" | "read" | "none";
+1 -1
View File
@@ -1,5 +1,5 @@
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {TTLCache} from "./cache.js"; import {TTLCache} from "./cache";
export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> { export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> {
return await cache.get(`/username`, undefined, () => fetchUsername(octokit)); return await cache.get(`/username`, undefined, () => fetchUsername(octokit));
+5 -5
View File
@@ -2,11 +2,11 @@ import {ValueProviderConfig} from "@actions/languageservice";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context"; import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {ValueProviderKind} from "@actions/languageservice/value-providers/config"; import {ValueProviderKind} from "@actions/languageservice/value-providers/config";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "./initializationOptions.js"; import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache.js"; import {TTLCache} from "./utils/cache";
import {getActionInputValues} from "./value-providers/action-inputs.js"; import {getActionInputValues} from "./value-providers/action-inputs";
import {getEnvironments} from "./value-providers/job-environment.js"; import {getEnvironments} from "./value-providers/job-environment";
import {getRunnerLabels} from "./value-providers/runs-on.js"; import {getRunnerLabels} from "./value-providers/runs-on";
export function valueProviders( export function valueProviders(
client: Octokit | undefined, client: Octokit | undefined,
@@ -3,8 +3,8 @@ import {WorkflowContext} from "@actions/languageservice/context/workflow-context
import {Value} from "@actions/languageservice/value-providers/config"; import {Value} from "@actions/languageservice/value-providers/config";
import {isActionStep} from "@actions/workflow-parser/model/type-guards"; import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata.js"; import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
export async function getActionInputs( export async function getActionInputs(
client: Octokit, client: Octokit,
@@ -1,6 +1,6 @@
import {Value} from "@actions/languageservice/value-providers/config"; import {Value} from "@actions/languageservice/value-providers/config";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> { export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () => const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
@@ -2,8 +2,8 @@ import {log} from "@actions/languageservice/log";
import {Value} from "@actions/languageservice/value-providers/config"; import {Value} from "@actions/languageservice/value-providers/config";
import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default"; import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache.js"; import {TTLCache} from "../utils/cache";
import {errorMessage} from "../utils/error.js"; import {errorMessage} from "../utils/error";
// Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners. // Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners.
// It doesn't return labels for organization runners visible to the repository. // It doesn't return labels for organization runners visible to the repository.
+1 -2
View File
@@ -5,7 +5,6 @@
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"noEmit": false, "noEmit": false,
"outDir": "./dist", "outDir": "./dist"
"skipLibCheck": true
} }
} }
+7 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/languageservice", "name": "@actions/languageservice",
"version": "0.3.54", "version": "0.3.22",
"description": "Language service for GitHub Actions", "description": "Language service for GitHub Actions",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -35,27 +35,24 @@
"clean": "rimraf dist", "clean": "rimraf dist",
"format": "prettier --write '**/*.ts'", "format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'", "format-check": "prettier --check '**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'", "lint-fix": "eslint --fix 'src/**/*.ts'",
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
"prebuild": "npm run minify-json",
"prepublishOnly": "npm run build && npm run test", "prepublishOnly": "npm run build && npm run test",
"pretest": "npm run minify-json",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest", "test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch", "test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"update-webhooks": "npx tsx script/webhooks/index.ts", "update-webhooks": "ts-node-esm script/webhooks/index.ts",
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"dependencies": { "dependencies": {
"@actions/expressions": "^0.3.54", "@actions/expressions": "^0.3.22",
"@actions/workflow-parser": "^0.3.54", "@actions/workflow-parser": "^0.3.22",
"vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2", "vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8", "vscode-uri": "^3.0.8",
"yaml": "^2.1.1" "yaml": "^2.1.1"
}, },
"engines": { "engines": {
"node": ">= 20" "node": ">= 16.15"
}, },
"files": [ "files": [
"dist/**/*" "dist/**/*"
@@ -74,6 +71,6 @@
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ts-jest": "^29.0.3", "ts-jest": "^29.0.3",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.8.3" "typescript": "^4.8.4"
} }
} }
+2 -273
View File
@@ -7,185 +7,6 @@ const schema = schemaImport as any;
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json"; const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
const OBJECTS_PATH = "./src/context-providers/events/objects.json"; const OBJECTS_PATH = "./src/context-providers/events/objects.json";
const ALL_OUTPUT_PATH = "./src/context-providers/events/webhooks.all.json";
const ALL_OBJECTS_PATH = "./src/context-providers/events/objects.all.json";
const DROP_OUTPUT_PATH = "./src/context-providers/events/webhooks.drop.json";
const DROP_OBJECTS_PATH = "./src/context-providers/events/objects.drop.json";
const STRIP_OUTPUT_PATH = "./src/context-providers/events/webhooks.strip.json";
const STRIP_OBJECTS_PATH = "./src/context-providers/events/objects.strip.json";
// Parse --all flag
const generateAll = process.argv.includes("--all");
// Events to drop - not valid workflow triggers (GitHub App or API-only events)
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const DROPPED_EVENTS = new Set([
"branch_protection_configuration",
"code_scanning_alert",
"commit_comment",
"custom_property",
"custom_property_values",
"dependabot_alert",
"deploy_key",
"github_app_authorization",
"installation",
"installation_repositories",
"installation_target",
"marketplace_purchase",
"member",
"membership",
"merge_group",
"meta",
"org_block",
"organization",
"package",
"personal_access_token_request",
"ping",
"repository",
"repository_advisory",
"repository_ruleset",
"secret_scanning_alert",
"secret_scanning_alert_location",
"security_advisory",
"security_and_analysis",
"sponsorship",
"star",
"team",
"team_add"
]);
// Events to keep - valid workflow triggers
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const KEPT_EVENTS = new Set([
"branch_protection_rule",
"check_run",
"check_suite",
"create",
"delete",
"deployment",
"deployment_status",
"discussion",
"discussion_comment",
"fork",
"gollum",
"issue_comment",
"issues",
"label",
"milestone",
"page_build",
"project",
"project_card",
"project_column",
"projects_v2",
"projects_v2_item",
"public",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
"pull_request_review_thread",
"push",
"registry_package",
"release",
"repository_dispatch",
"repository_import",
"repository_vulnerability_alert",
"status",
"watch",
"workflow_dispatch",
"workflow_job",
"workflow_run"
]);
/**
* Fields to strip from the JSON data.
*
* EVENT_ACTION_FIELDS: stripped from each event action object (top level only)
* Example event action object before stripping:
* {
* "description": "This event is triggered when...", // <-- stripped
* "summary": "A brief summary", // <-- stripped
* "availability": ["repository"], // <-- stripped
* "category": "issues", // <-- stripped
* "action": "opened", // kept
* "bodyParameters": [...] // kept
* }
*
* BODY_PARAM_FIELDS: stripped from every bodyParameters object, recursively through childParamsGroups
* Example bodyParameter object before stripping:
* {
* "type": "object", // <-- stripped
* "name": "changes", // kept (used for property names)
* "in": "body", // <-- stripped
* "description": "The changes that were made.", // kept (used for hover docs)
* "isRequired": true, // <-- stripped
* "enum": ["a", "b"], // <-- stripped
* "default": "a", // <-- stripped
* "childParamsGroups": [ // kept (used for nested properties)
* {
* "type": "string", // <-- stripped (recursive)
* "name": "from", // kept
* "isRequired": true // <-- stripped (recursive)
* }
* ]
* }
*/
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category"];
const BODY_PARAM_FIELDS = ["type", "in", "isRequired", "enum", "default"];
/**
* Strip fields from a bodyParameter object and recursively from childParamsGroups.
*/
function stripBodyParam(param: any): any {
if (typeof param !== "object" || param === null) {
return param;
}
const result: any = {};
for (const [key, value] of Object.entries(param)) {
if (BODY_PARAM_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "childParamsGroups" && Array.isArray(value)) {
result[key] = value.map(stripBodyParam);
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from event action data.
*/
function stripEventActionFields(action: any): any {
const result: any = {};
for (const [key, value] of Object.entries(action)) {
if (EVENT_ACTION_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "bodyParameters" && Array.isArray(value)) {
result[key] = value.map((p: any) => (typeof p === "number" ? p : stripBodyParam(p)));
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from all webhooks.
* Structure: { eventName: { actionName: { ...fields } } }
*/
function stripFields(webhooks: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
const result: Record<string, Record<string, any>> = {};
for (const [eventName, actions] of Object.entries(webhooks)) {
result[eventName] = {};
for (const [actionName, actionData] of Object.entries(actions)) {
result[eventName][actionName] = stripEventActionFields(actionData);
}
}
return result;
}
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[]; const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
if (!rawWebhooks) { if (!rawWebhooks) {
@@ -199,51 +20,11 @@ for (const webhook of Object.values(rawWebhooks)) {
await Promise.all(webhooks.map(webhook => webhook.process())); await Promise.all(webhooks.map(webhook => webhook.process()));
// Check for unknown events (not in DROPPED_EVENTS or KEPT_EVENTS)
const unknownEvents: string[] = [];
for (const webhook of webhooks) {
if (!DROPPED_EVENTS.has(webhook.category) && !KEPT_EVENTS.has(webhook.category)) {
if (!unknownEvents.includes(webhook.category)) {
unknownEvents.push(webhook.category);
}
}
}
if (unknownEvents.length > 0) {
console.error("");
console.error("══════════════════════════════════════════════════════════════════");
console.error("ERROR: New webhook event(s) detected!");
console.error("══════════════════════════════════════════════════════════════════");
console.error("");
console.error("The following events are not categorized:");
for (const event of unknownEvents.sort()) {
console.error(` - ${event}`);
}
console.error("");
console.error("Action required:");
console.error(" 1. Check if the event is a valid workflow trigger:");
console.error(
" https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows"
);
console.error("");
console.error(" 2. Add the event to DROPPED_EVENTS or KEPT_EVENTS in:");
console.error(" languageservice/script/webhooks/index.ts");
console.error("");
console.error(" 3. See docs/json-data-files.md for more details.");
console.error("");
process.exit(1);
}
// The category is the name of the webhook // The category is the name of the webhook
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {}; const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
for (const webhook of webhooks) { for (const webhook of webhooks) {
if (!webhook.action) webhook.action = "default"; if (!webhook.action) webhook.action = "default";
// Drop unused events
if (DROPPED_EVENTS.has(webhook.category)) {
continue;
}
if (categorizedWebhooks[webhook.category]) { if (categorizedWebhooks[webhook.category]) {
categorizedWebhooks[webhook.category][webhook.action] = webhook; categorizedWebhooks[webhook.category][webhook.action] = webhook;
} else { } else {
@@ -252,59 +33,7 @@ for (const webhook of webhooks) {
} }
} }
// Strip fields before deduplication const objectsArray = deduplicateWebhooks(categorizedWebhooks);
const strippedWebhooks = stripFields(categorizedWebhooks);
// Deduplicate after dropping and stripping
const objectsArray = deduplicateWebhooks(strippedWebhooks);
// Write optimized output
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2)); await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, null, 2)); await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, 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)`);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {actionIdentifier, parseActionReference as parse} from "./action.js"; import {actionIdentifier, parseActionReference as parse} from "./action";
describe("parseActionReference", () => { describe("parseActionReference", () => {
it("basic action", () => { it("basic action", () => {
@@ -1,55 +0,0 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
import {CodeActionContext, CodeActionProvider} from "./types.js";
import {getQuickfixProviders} from "./quickfix/quickfix-providers.js";
export interface CodeActionParams {
uri: string;
documentContent: string;
diagnostics: Diagnostic[];
only?: string[];
featureFlags?: FeatureFlags;
}
export function getCodeActions(params: CodeActionParams): CodeAction[] {
const actions: CodeAction[] = [];
const context: CodeActionContext = {
uri: params.uri,
documentContent: params.documentContent,
featureFlags: params.featureFlags
};
// Build providers map based on feature flags
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
[CodeActionKind.QuickFix, getQuickfixProviders(params.featureFlags)]
// [CodeActionKind.Refactor, getRefactorProviders(params.featureFlags)],
// [CodeActionKind.Source, getSourceProviders(params.featureFlags)],
// etc
]);
// Filter to requested kinds, or use all if none specified
const requestedKinds = params.only;
const kindsToCheck = requestedKinds
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
: [...providersByKind.keys()];
for (const diagnostic of params.diagnostics) {
for (const kind of kindsToCheck) {
const providers = providersByKind.get(kind) ?? [];
for (const provider of providers) {
if (provider.diagnosticCodes.includes(diagnostic.code)) {
const action = provider.createCodeAction(context, diagnostic);
if (action) {
action.kind = kind;
action.diagnostics = [diagnostic];
actions.push(action);
}
}
}
}
}
return actions;
}
export type {CodeActionContext, CodeActionProvider} from "./types.js";
@@ -1,245 +0,0 @@
import {isMapping} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {CodeAction, Position, TextEdit} from "vscode-languageserver-types";
import {error} from "../../log.js";
import {findToken} from "../../utils/find-token.js";
import {getOrParseWorkflow} from "../../utils/workflow-cache.js";
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js";
import {CodeActionContext, CodeActionProvider} from "../types.js";
/**
* Information extracted from a step token needed to generate edits
*/
interface StepInfo {
/** Column where step keys start (1-indexed), e.g., the column of "uses:" */
stepKeyColumn: number;
/** End line of the step (1-indexed) */
stepEndLine: number;
/** Detected indent size (spaces per level) */
indentSize: number;
/** Information about existing with: block, if present */
withInfo?: {
keyColumn: number;
keyEndLine: number;
valueEndLine: number;
hasChildren: boolean;
/** Column of first child input (1-indexed), for indentation detection */
firstChildColumn?: number;
};
}
export const addMissingInputsProvider: CodeActionProvider = {
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
createCodeAction(context: CodeActionContext, diagnostic): CodeAction | undefined {
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
if (!data) {
return undefined;
}
// Parse the document to get the step token
const stepInfo = getStepInfo(context, diagnostic.range.start);
if (!stepInfo) {
return undefined;
}
const edits = createInputEdits(data.missingInputs, stepInfo);
if (!edits || edits.length === 0) {
return undefined;
}
const inputNames = data.missingInputs.map(i => i.name).join(", ");
return {
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
edit: {
changes: {
[context.uri]: edits
}
}
};
}
};
/**
* Parse the document and extract step information needed for generating edits.
* Returns undefined if parsing fails or the step token cannot be found.
*/
function getStepInfo(context: CodeActionContext, diagnosticPosition: Position): StepInfo | undefined {
// Parse the document (uses cache if available from validation)
const file = {name: context.uri, content: context.documentContent};
const parseResult = getOrParseWorkflow(file, context.uri);
if (!parseResult.value) {
error("Failed to parse workflow for missing inputs quickfix");
return undefined;
}
// Find the token at the diagnostic position
const {path} = findToken(diagnosticPosition, parseResult.value);
// Walk up the path to find the step token (regular-step)
const stepToken = findStepInPath(path);
if (!stepToken) {
error("Could not find step token for missing inputs quickfix");
return undefined;
}
return extractStepInfo(stepToken);
}
/**
* Find the step token (regular-step) in the token path
*/
function findStepInPath(path: TemplateToken[]): MappingToken | undefined {
// Walk backwards through path to find the step
for (let i = path.length - 1; i >= 0; i--) {
if (path[i].definition?.key === "regular-step" && isMapping(path[i])) {
return path[i] as MappingToken;
}
}
return undefined;
}
/**
* Extract position and indentation info from a step token
*/
function extractStepInfo(stepToken: MappingToken): StepInfo | undefined {
if (!stepToken.range) {
return undefined;
}
// Get the column of the first key in the step
let stepKeyColumn = stepToken.range.start.column;
if (stepToken.count > 0) {
const firstEntry = stepToken.get(0);
if (firstEntry?.key.range) {
stepKeyColumn = firstEntry.key.range.start.column;
}
}
// Find the with: block if present
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
break;
}
}
// Calculate indent size
let indentSize = 2; // Default
let withInfo: StepInfo["withInfo"];
if (withKey?.range && withToken?.range) {
// Has with: block - extract its info
const hasChildren = isMapping(withToken) && withToken.count > 0;
let firstChildColumn: number | undefined;
if (hasChildren) {
const firstChild = (withToken as MappingToken).get(0);
if (firstChild?.key.range) {
firstChildColumn = firstChild.key.range.start.column;
// Detect indent size from with: children
indentSize = firstChildColumn - withKey.range.start.column;
}
}
withInfo = {
keyColumn: withKey.range.start.column,
keyEndLine: withKey.range.end.line,
valueEndLine: withToken.range.end.line,
hasChildren,
firstChildColumn
};
} else {
// No with: block - detect indent size using heuristics
// Based on the step key column position, estimate indent size
// 2-space indent files typically have step keys at column 7
// 4-space indent files typically have step keys at column 15
const zeroIndexedCol = stepKeyColumn - 1;
if (zeroIndexedCol >= 10) {
indentSize = 4;
}
}
return {
stepKeyColumn,
stepEndLine: stepToken.range.end.line,
indentSize,
withInfo
};
}
/**
* Generate text edits to add missing inputs
*/
function createInputEdits(missingInputs: MissingInputsDiagnosticData["missingInputs"], stepInfo: StepInfo): TextEdit[] {
const formatInputLines = (indent: string) =>
missingInputs.map(input => {
const value = input.default ?? '""';
return `${indent}${input.name}: ${value}`;
});
if (stepInfo.withInfo) {
// `with:` exists - add inputs to existing block
const withIndent = stepInfo.withInfo.keyColumn - 1; // 0-indexed
const inputIndentSize = stepInfo.withInfo.firstChildColumn
? stepInfo.withInfo.firstChildColumn - stepInfo.withInfo.keyColumn
: stepInfo.indentSize;
const inputIndent = " ".repeat(withIndent + inputIndentSize);
const inputLines = formatInputLines(inputIndent);
// Calculate insert position
let insertLine: number;
if (stepInfo.withInfo.hasChildren) {
// Insert after the last child (at end of with: block)
// valueEndLine is 1-indexed, we want 0-indexed for Position
insertLine = stepInfo.withInfo.valueEndLine - 1;
} else {
// Empty with: block - insert on the next line after with:
// keyEndLine is 1-indexed, convert to 0-indexed and go to next line
insertLine = stepInfo.withInfo.keyEndLine;
}
const insertPosition: Position = {
line: insertLine,
character: 0
};
return [
{
range: {start: insertPosition, end: insertPosition},
newText: inputLines.map(line => line + "\n").join("")
}
];
} else {
// No `with:` key - add `with:` at the same level as other step keys
const withKeyIndent = stepInfo.stepKeyColumn - 1; // 0-indexed (columns are 1-based)
const withIndent = " ".repeat(withKeyIndent);
const inputIndent = " ".repeat(withKeyIndent + stepInfo.indentSize);
const inputLines = formatInputLines(inputIndent);
const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");
// Insert at end of step
// stepEndLine is 1-indexed, we want 0-indexed and insert before the line after
const insertPosition: Position = {
line: stepInfo.stepEndLine - 1,
character: 0
};
return [
{
range: {start: insertPosition, end: insertPosition},
newText
}
];
}
}
@@ -1,13 +0,0 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeActionProvider} from "../types.js";
import {addMissingInputsProvider} from "./add-missing-inputs.js";
export function getQuickfixProviders(featureFlags?: FeatureFlags): CodeActionProvider[] {
const providers: CodeActionProvider[] = [];
if (featureFlags?.isEnabled("missingInputsQuickfix")) {
providers.push(addMissingInputsProvider);
}
return providers;
}
@@ -1,90 +0,0 @@
import * as path from "path";
import {fileURLToPath} from "url";
import {loadTestCases, runTestCase} from "./runner.js";
import {ValidationConfig} from "../../validate.js";
import {ActionMetadata, ActionReference} from "../../action.js";
import {clearCache} from "../../utils/workflow-cache.js";
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Mock action metadata provider for tests
const validationConfig: ValidationConfig = {
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
const metadata: Record<string, ActionMetadata> = {
"actions/cache@v1": {
name: "Cache",
description: "Cache dependencies",
inputs: {
path: {
description: "A list of files to cache",
required: true
},
key: {
description: "Cache key",
required: true
},
"restore-keys": {
description: "Restore keys",
required: false
}
}
},
"actions/setup-node@v3": {
name: "Setup Node",
description: "Setup Node.js",
inputs: {
"node-version": {
description: "Node version",
required: true,
default: "16"
}
}
}
};
return Promise.resolve(metadata[key]);
}
}
};
// Point to the source testdata directory
const testdataDir = path.join(__dirname, "testdata");
beforeEach(() => {
clearCache();
});
describe("code action golden tests", () => {
const testCases = loadTestCases(testdataDir);
if (testCases.length === 0) {
it.todo("no test cases found - add .yml files to testdata/");
return;
}
for (const testCase of testCases) {
it(testCase.name, async () => {
const result = await runTestCase(testCase, validationConfig);
if (!result.passed) {
let errorMessage = result.error || "Test failed";
if (result.expected !== undefined && result.actual !== undefined) {
errorMessage += "\n\n";
errorMessage += "=== EXPECTED (golden file) ===\n";
errorMessage += result.expected;
errorMessage += "\n\n";
errorMessage += "=== ACTUAL ===\n";
errorMessage += result.actual;
}
throw new Error(errorMessage);
}
});
}
});
@@ -1,231 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import {TextEdit} from "vscode-languageserver-types";
import {TextDocument} from "vscode-languageserver-textdocument";
import {FeatureFlags} from "@actions/expressions";
import {validate, ValidationConfig} from "../../validate.js";
import {getCodeActions, CodeActionParams} from "../code-actions.js";
// Marker pattern: # want "diagnostic message" fix="code-action-name"
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
export interface TestCase {
name: string;
inputPath: string;
goldenPath: string;
input: string;
golden: string;
markers: Marker[];
}
export interface Marker {
line: number;
message: string;
fix?: string;
}
export interface TestResult {
name: string;
passed: boolean;
error?: string;
expected?: string;
actual?: string;
}
/**
* Parse markers from input file content
*/
export function parseMarkers(content: string): Marker[] {
const lines = content.split("\n");
const markers: Marker[] = [];
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(MARKER_PATTERN);
if (match) {
markers.push({
line: i,
message: match[1],
fix: match[2]
});
}
}
return markers;
}
/**
* Strip markers from content (for processing)
*/
export function stripMarkers(content: string): string {
return content
.split("\n")
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
.join("\n");
}
/**
* Load all test cases from a testdata directory
*/
export function loadTestCases(testdataDir: string): TestCase[] {
const testCases: TestCase[] = [];
function walkDir(dir: string) {
const entries = fs.readdirSync(dir, {withFileTypes: true});
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(".golden.yml")) {
const goldenPath = fullPath.replace(".yml", ".golden.yml");
if (fs.existsSync(goldenPath)) {
const input = fs.readFileSync(fullPath, "utf-8");
const golden = fs.readFileSync(goldenPath, "utf-8");
testCases.push({
name: path.relative(testdataDir, fullPath),
inputPath: fullPath,
goldenPath,
input,
golden,
markers: parseMarkers(input)
});
}
}
}
}
walkDir(testdataDir);
return testCases;
}
/**
* Apply text edits to a document
*/
export function applyEdits(content: string, edits: TextEdit[]): string {
// Sort edits in reverse order by position to apply from bottom to top
const sortedEdits = [...edits].sort((a, b) => {
if (b.range.start.line !== a.range.start.line) {
return b.range.start.line - a.range.start.line;
}
return b.range.start.character - a.range.start.character;
});
const lines = content.split("\n");
for (const edit of sortedEdits) {
const startLine = edit.range.start.line;
const startChar = edit.range.start.character;
const endLine = edit.range.end.line;
const endChar = edit.range.end.character;
const before = lines[startLine].slice(0, startChar);
const after = lines[endLine].slice(endChar);
const newLines = edit.newText.split("\n");
newLines[0] = before + newLines[0];
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
lines.splice(startLine, endLine - startLine + 1, ...newLines);
}
return lines.join("\n");
}
/**
* Run a single test case
*/
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
const strippedInput = stripMarkers(testCase.input);
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
// 1. Validate and get diagnostics
const diagnostics = await validate(document, validationConfig);
// 2. Verify all expected diagnostics are present
const missingDiagnostics: string[] = [];
for (const marker of testCase.markers) {
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!found) {
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
}
}
if (missingDiagnostics.length > 0) {
return {
name: testCase.name,
passed: false,
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
"\n "
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
};
}
// 3. Collect all edits from all matching code actions
const allEdits: TextEdit[] = [];
for (const marker of testCase.markers) {
if (!marker.fix) {
continue;
}
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!diagnostic) {
continue; // Already reported above
}
const params: CodeActionParams = {
uri: document.uri,
documentContent: strippedInput,
diagnostics: [diagnostic],
featureFlags: new FeatureFlags({all: true})
};
const actions = getCodeActions(params);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- marker.fix is checked at the start of the loop
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
if (!matchingAction) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${
actions.map(a => a.title).join(", ") || "(none)"
}`
};
}
if (!matchingAction.edit?.changes) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" has no edits`
};
}
const edits = matchingAction.edit.changes[document.uri] || [];
allEdits.push(...edits);
}
// 4. Apply all edits and compare to golden file
const actualOutput = applyEdits(strippedInput, allEdits);
const expectedOutput = testCase.golden;
if (actualOutput.trim() !== expectedOutput.trim()) {
return {
name: testCase.name,
passed: false,
error: "Output does not match golden file",
expected: expectedOutput,
actual: actualOutput
};
}
return {
name: testCase.name,
passed: true
};
}
@@ -1,9 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -1,7 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -1,10 +0,0 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: ${{ runner.os }}-
path: ""
key: ""

Some files were not shown because too many files have changed in this diff Show More