Compare commits

..

1 Commits

Author SHA1 Message Date
Felipe Suero 49b80c3ef0 switch all to main and pull 2023-04-17 14:35:54 -04:00
256 changed files with 141162 additions and 52644 deletions
+1 -1
View File
@@ -1 +1 @@
* @actions/actions-vscode-reviewers
* @actions/actions-workflow-development-reviewers
-16
View File
@@ -1,16 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directories:
- "/"
- "/languageservice"
- "/languageserver"
- "expressions"
- "browser-playground"
schedule:
interval: "weekly"
+5 -44
View File
@@ -1,6 +1,4 @@
name: Build & Test
permissions:
contents: read
on:
push:
@@ -12,55 +10,18 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
- uses: actions/checkout@v3
- name: Use Node.js 16.15
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
node-version: 16.15
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
- run: npm ci --engine-strict
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm run format-check -ws
- run: npm run build -ws
- run: npm run lint -ws
- run: npm test -ws
check-generated:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Regenerate JSON files
run: |
cd languageservice && npm run update-webhooks && cd ..
- name: Check for uncommitted changes
run: |
if ! git diff --exit-code; then
echo ""
echo "=========================================="
echo "ERROR: Generated files are out of date!"
echo "=========================================="
echo ""
echo "Please run the following commands locally and commit the changes:"
echo ""
echo " cd languageservice && npm run update-webhooks && cd .."
echo " git add -A && git commit -m 'Regenerate JSON files'"
echo ""
exit 1
fi
+11 -22
View File
@@ -1,18 +1,13 @@
name: Create release PR
run-name: Create release PR for new ${{ github.event.inputs.version }} version
run-name: Create release PR for v${{ github.event.inputs.version }}
on:
workflow_dispatch:
inputs:
version:
required: true
type: choice
description: "What type of release is this"
options:
- "major"
- "minor"
- "patch"
description: "Version to bump `package.json` to (format: x.y.z)"
jobs:
create-release-pr:
@@ -25,9 +20,9 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: "16"
@@ -36,27 +31,21 @@ jobs:
git config --global user.email "github-actions@github.com"
git config --global user.name "GitHub Actions"
NEW_VERSION=$(./script/workflows/increment-version.sh ${{ inputs.version }})
git checkout -b release/${{ inputs.version }}
git checkout -b release/$NEW_VERSION
npx lerna version $NEW_VERSION --yes --no-push --no-git-tag-version --force-publish
npx lerna version ${{ inputs.version }} --yes --no-push --no-git-tag-version --force-publish
git add **/package.json package-lock.json lerna.json
git commit -m "Release extension version $NEW_VERSION"
git commit -m "Release extension version ${{ inputs.version }}"
git push --set-upstream origin release/$NEW_VERSION
echo "new_version=$NEW_VERSION" >> $GITHUB_ENV
git push --set-upstream origin release/${{ inputs.version }}
- name: Create PR
run: |
LAST_PR=$(gh pr list --repo ${{ github.repository }} --limit 1 --state merged --search "Release version" --json number | jq -r '.[0].number')
./script/workflows/generate-release-notes.sh $LAST_PR ${{ env.new_version }}
gh pr create \
--title "Release version ${{ env.new_version }}" \
--body-file releasenotes.md \
--title "Release version ${{ inputs.version }}" \
--body "Release version ${{ inputs.version }}" \
--base main \
--head release/${{ env.new_version }}
--head release/${{ inputs.version }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -6
View File
@@ -24,10 +24,10 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Check if version has changed
id: check-version
uses: actions/github-script@v7
uses: actions/github-script@v6
with:
script: |
const version = '${{ inputs.version }}' || require('./lerna.json').version;
@@ -65,11 +65,11 @@ jobs:
PKG_VERSION: "" # will be set in the workflow
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: 22.x
node-version: 16.x
cache: "npm"
scope: '@actions'
@@ -80,7 +80,7 @@ jobs:
- run: npm ci
- name: Create release
uses: actions/github-script@v7
uses: actions/github-script@v6
with:
script: |
const fs = require("fs");
+2 -13
View File
@@ -1,16 +1,5 @@
*/node_modules
*/dist
lerna-debug.log
node_modules
.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
.DS_Store
-59
View File
@@ -1,59 +0,0 @@
# PR #283 Review: Use property descriptions for completion items
## Summary
This PR fixes a bug where completion items for action.yml were missing descriptions. The root cause was that `mappingValues()` only looked at the type definition's description, ignoring property-level descriptions in the schema.
## Changes Analysis
### Core Fix ([definition.ts](languageservice/src/value-providers/definition.ts))
**Before:**
```typescript
let description: string | undefined;
if (value.type) {
const typeDef = definitions[value.type];
description = typeDef?.description;
}
```
**After:**
```typescript
let description: string | undefined = value.description;
if (value.type) {
const typeDef = definitions[value.type];
if (!description) {
description = typeDef?.description;
}
}
```
**Correct approach** - prioritizes property description, falls back to type description.
### Test Coverage
1. **complete-action.test.ts**: Two new tests verify `author` and `branding` completions include documentation
2. **hover-action.test.ts**: New test for `author` hover + updated `branding` test to verify "Documentation" link
## Potential Issues
### 1. One-of expansion doesn't use property description
Looking at line 140-142:
```typescript
const expanded = expandOneOfToCompletions(oneOfDef, definitions, key, description, indentation, mode);
```
This passes `description` to `expandOneOfToCompletions`, but at this point `description` may have been populated from the property. **This is correct** - the property description is passed through.
### 2. Consistency check
The PR description mentions this is consistent with hover. Verified: [template-reader.ts#L225](workflow-parser/src/templates/template-reader.ts#L225) shows hover uses `nextPropertyDef.description` when available.
## Verdict
**LGTM** - Clean, minimal fix that aligns completion behavior with hover. Good test coverage for the specific cases mentioned.
## Minor Suggestions (non-blocking)
1. Could add a test for a property that has NO description but whose type DOES have one, to verify fallback works (e.g., `inputs` which references `inputs-strict` type that has a description)
+2 -20
View File
@@ -8,24 +8,6 @@ This repository contains multiple npm packages for working with GitHub Actions w
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
- [browser-playground](./browser-playground) - Browser-based playground for the language service
## Documentation
## Contributing
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
### Note
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
We continue to focus our resources on strategic areas that help our customers be successful while making developers' lives easier. While GitHub Actions remains a key part of this vision, we are allocating resources towards other areas of Actions and are not taking contributions to this repository at this time. The GitHub public roadmap is the best place to follow along for any updates on features were working on and what stage theyre in.
We are taking the following steps to better direct requests related to GitHub Actions, including:
1. We will be directing questions and support requests to our [Community Discussions area](https://github.com/orgs/community/discussions/categories/actions)
2. High Priority bugs can be reported through Community Discussions or you can report these to our support team https://support.github.com/contact/bug-report.
3. Security Issues should be handled as per our [security.md](security.md)
We will still provide security updates for this project and fix major breaking changes during this time.
You are welcome to still raise bugs in this repo.
See [CONTRIBUTING.md](./CONTRIBUTING.md)
+1 -1
View File
@@ -34,6 +34,6 @@
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": ">=5.2.1"
"webpack-dev-server": "^4.11.1"
}
}
-299
View File
@@ -1,299 +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.
## 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:** Verified December 2025. Version 9.0.1 is available but ESM export support is not confirmed.
**Current Decision:** The languageserver package is **deferred** from this migration until the upstream `vscode-languageserver` package adds proper ESM exports. It will continue using the old `moduleResolution: "node"` configuration.
**Options to resolve:**
- Wait for vscode-languageserver to add ESM exports
- Try upgrading to vscode-languageserver v9.x to see if exports were added
- Use a bundler to work around the module resolution
- Fork or patch the dependency
---
## Migration Status
| Package | Tests | ESM Status |
|---------|-------|------------|
| expressions | 1068 | ✅ Migrated |
| workflow-parser | 292 | ✅ Migrated |
| languageservice | 452 | ✅ Migrated |
| languageserver | 6 files | ⏸️ Deferred (vscode-languageserver lacks ESM exports) |
---
## 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`)
+6 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.33",
"version": "0.3.3",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -9,12 +9,10 @@
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
"import": "./dist/index.js"
},
"./*": {
"import": "./dist/*.js",
"types": "./dist/*.d.ts"
"import": "./dist/*.js"
}
},
"typesVersions": {
@@ -36,7 +34,7 @@
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"prepublishOnly": "npm run build && npm run test",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
@@ -44,7 +42,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"engines": {
"node": ">= 18"
"node": ">= 16.15"
},
"files": [
"dist/**/*"
@@ -60,6 +58,6 @@
"prettier": "^2.8.3",
"rimraf": "^3.0.2",
"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 {Token} from "./lexer.js";
import {ExpressionData} from "./data";
import {Token} from "./lexer";
export interface ExprVisitor<R> {
visitLiteral(literal: Literal): R;
+8 -8
View File
@@ -1,11 +1,11 @@
import {complete, CompletionItem, trimTokenVector} from "./completion.js";
import {DescriptionDictionary} from "./completion/descriptionDictionary.js";
import {BooleanData} from "./data/boolean.js";
import {Dictionary} from "./data/dictionary.js";
import {StringData} from "./data/string.js";
import {wellKnownFunctions} from "./funcs.js";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
import {Lexer, TokenType} from "./lexer.js";
import {complete, CompletionItem, trimTokenVector} from "./completion";
import {DescriptionDictionary} from "./completion/descriptionDictionary";
import {BooleanData} from "./data/boolean";
import {Dictionary} from "./data/dictionary";
import {StringData} from "./data/string";
import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {Lexer, TokenType} from "./lexer";
const testContext = new Dictionary(
{
+8 -8
View File
@@ -1,11 +1,11 @@
import {DescriptionPair} from "./completion/descriptionDictionary.js";
import {Dictionary, isDictionary} from "./data/dictionary.js";
import {ExpressionData} from "./data/expressiondata.js";
import {Evaluator} from "./evaluator.js";
import {wellKnownFunctions} from "./funcs.js";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
import {Lexer, Token, TokenType} from "./lexer.js";
import {Parser} from "./parser.js";
import {DescriptionPair} from "./completion/descriptionDictionary";
import {Dictionary, isDictionary} from "./data/dictionary";
import {ExpressionData} from "./data/expressiondata";
import {Evaluator} from "./evaluator";
import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {Lexer, Token, TokenType} from "./lexer";
import {Parser} from "./parser";
export type CompletionItem = {
label: string;
@@ -1,5 +1,5 @@
import {StringData} from "../data/index.js";
import {DescriptionDictionary} from "./descriptionDictionary.js";
import {StringData} from "../data";
import {DescriptionDictionary} from "./descriptionDictionary";
describe("description dictionary", () => {
it("pairs contains all values", () => {
@@ -1,5 +1,5 @@
import {Dictionary} from "../data/dictionary.js";
import {ExpressionData, Kind, Pair} from "../data/expressiondata.js";
import {Dictionary} from "../data/dictionary";
import {ExpressionData, Kind, Pair} from "../data/expressiondata";
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 {
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 {
constructor(public readonly value: boolean) {}
+2 -2
View File
@@ -1,5 +1,5 @@
import {Dictionary} from "./dictionary.js";
import {StringData} from "./string.js";
import {Dictionary} from "./dictionary";
import {StringData} from "./string";
describe("dictionary", () => {
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 {
private keys: string[] = [];
+6 -6
View File
@@ -1,9 +1,9 @@
import {Dictionary} from "./dictionary.js";
import {Null} from "./null.js";
import {Array} from "./array.js";
import {StringData} from "./string.js";
import {NumberData} from "./number.js";
import {BooleanData} from "./boolean.js";
import {Dictionary} from "./dictionary";
import {Null} from "./null";
import {Array} from "./array";
import {StringData} from "./string";
import {NumberData} from "./number";
import {BooleanData} from "./boolean";
export enum Kind {
String = 0,
+9 -9
View File
@@ -1,9 +1,9 @@
export {Array} from "./array.js";
export {BooleanData} from "./boolean.js";
export {Dictionary} from "./dictionary.js";
export {ExpressionData, Kind} from "./expressiondata.js";
export {Null} from "./null.js";
export {NumberData} from "./number.js";
export {replacer} from "./replacer.js";
export {reviver} from "./reviver.js";
export {StringData} from "./string.js";
export {Array} from "./array";
export {BooleanData} from "./boolean";
export {Dictionary} from "./dictionary";
export {ExpressionData, Kind} from "./expressiondata";
export {Null} from "./null";
export {NumberData} from "./number";
export {replacer} from "./replacer";
export {reviver} from "./reviver";
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 {
public readonly kind = Kind.Null;
+1 -1
View File
@@ -1,4 +1,4 @@
import {NumberData} from "./number.js";
import {NumberData} from "./number";
describe("number", () => {
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 {
constructor(public readonly value: number) {}
+6 -6
View File
@@ -1,9 +1,9 @@
import {Array} from "./array.js";
import {Dictionary} from "./dictionary.js";
import {Null} from "./null.js";
import {NumberData} from "./number.js";
import {replacer} from "./replacer.js";
import {StringData} from "./string.js";
import {Array} from "./array";
import {Dictionary} from "./dictionary";
import {Null} from "./null";
import {NumberData} from "./number";
import {replacer} from "./replacer";
import {StringData} from "./string";
describe("replacer", () => {
it("null", () => {
+6 -6
View File
@@ -1,9 +1,9 @@
import {Array} from "./array.js";
import {BooleanData} from "./boolean.js";
import {Dictionary} from "./dictionary.js";
import {Null} from "./null.js";
import {NumberData} from "./number.js";
import {StringData} from "./string.js";
import {Array} from "./array";
import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary";
import {Null} from "./null";
import {NumberData} from "./number";
import {StringData} from "./string";
/**
* 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 {BooleanData} from "./boolean.js";
import {Dictionary} from "./dictionary.js";
import {ExpressionData} from "./expressiondata.js";
import {Null} from "./null.js";
import {NumberData} from "./number.js";
import {reviver} from "./reviver.js";
import {StringData} from "./string.js";
import {Array} from "./array";
import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary";
import {ExpressionData} from "./expressiondata";
import {Null} from "./null";
import {NumberData} from "./number";
import {reviver} from "./reviver";
import {StringData} from "./string";
describe("reviver", () => {
const tests: {
+7 -7
View File
@@ -1,10 +1,10 @@
import {Array as dArray} from "./array.js";
import {BooleanData} from "./boolean.js";
import {Dictionary} from "./dictionary.js";
import {ExpressionData} from "./expressiondata.js";
import {Null} from "./null.js";
import {NumberData} from "./number.js";
import {StringData} from "./string.js";
import {Array as dArray} from "./array";
import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary";
import {ExpressionData} from "./expressiondata";
import {Null} from "./null";
import {NumberData} from "./number";
import {StringData} from "./string";
/**
* 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 {
constructor(public readonly value: string) {}
+1 -1
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_EXPRESSION_LENGTH = 21000;
+5 -5
View File
@@ -1,8 +1,8 @@
import * as data from "./data/index.js";
import {ExpressionEvaluationError} from "./errors.js";
import {Evaluator} from "./evaluator.js";
import {Lexer} from "./lexer.js";
import {Parser} from "./parser.js";
import * as data from "./data";
import {ExpressionEvaluationError} from "./errors";
import {Evaluator} from "./evaluator";
import {Lexer} from "./lexer";
import {Parser} from "./parser";
describe("evaluator", () => {
const lexAndParse = (input: string) => {
+8 -8
View File
@@ -10,14 +10,14 @@ import {
Logical,
Star,
Unary
} from "./ast.js";
import * as data from "./data/index.js";
import {FilteredArray} from "./filtered_array.js";
import {wellKnownFunctions} from "./funcs.js";
import {FunctionDefinition} from "./funcs/info.js";
import {idxHelper} from "./idxHelper.js";
import {TokenType} from "./lexer.js";
import {equals, falsy, greaterThan, lessThan, truthy} from "./result.js";
} from "./ast";
import * as data from "./data";
import {FilteredArray} from "./filtered_array";
import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition} from "./funcs/info";
import {idxHelper} from "./idxHelper";
import {TokenType} from "./lexer";
import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
export class Evaluator implements ExprVisitor<data.ExpressionData> {
/**
+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 {}
+10 -10
View File
@@ -1,13 +1,13 @@
import {ErrorType, ExpressionError} from "./errors.js";
import {contains} from "./funcs/contains.js";
import {endswith} from "./funcs/endswith.js";
import {format} from "./funcs/format.js";
import {fromjson} from "./funcs/fromjson.js";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
import {join} from "./funcs/join.js";
import {startswith} from "./funcs/startswith.js";
import {tojson} from "./funcs/tojson.js";
import {Token} from "./lexer.js";
import {ErrorType, ExpressionError} from "./errors";
import {contains} from "./funcs/contains";
import {endswith} from "./funcs/endswith";
import {format} from "./funcs/format";
import {fromjson} from "./funcs/fromjson";
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {join} from "./funcs/join";
import {startswith} from "./funcs/startswith";
import {tojson} from "./funcs/tojson";
import {Token} from "./lexer";
export type ParseContext = {
allowUnknownKeywords: boolean;
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData, Kind} from "../data/index.js";
import {equals} from "../result.js";
import {FunctionDefinition} from "./info.js";
import {BooleanData, ExpressionData, Kind} from "../data";
import {equals} from "../result";
import {FunctionDefinition} from "./info";
export const contains: FunctionDefinition = {
name: "contains",
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData} from "../data/index.js";
import {toUpperSpecial} from "../result.js";
import {FunctionDefinition} from "./info.js";
import {BooleanData, ExpressionData} from "../data";
import {toUpperSpecial} from "../result";
import {FunctionDefinition} from "./info";
export const endswith: FunctionDefinition = {
name: "endsWith",
+2 -2
View File
@@ -1,5 +1,5 @@
import {Null, NumberData, StringData} from "../data/index.js";
import {format} from "./format.js";
import {Null, NumberData, StringData} from "../data";
import {format} from "./format";
describe("format", () => {
it("null", () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData, StringData} from "../data/index.js";
import {FunctionDefinition} from "./info.js";
import {ExpressionData, StringData} from "../data";
import {FunctionDefinition} from "./info";
export const format: FunctionDefinition = {
name: "format",
+4 -4
View File
@@ -1,7 +1,7 @@
import {ExpressionData} from "../data/index.js";
import {reviver} from "../data/reviver.js";
import {ExpressionEvaluationError} from "../errors.js";
import {FunctionDefinition} from "./info.js";
import {ExpressionData} from "../data";
import {reviver} from "../data/reviver";
import {ExpressionEvaluationError} from "../errors";
import {FunctionDefinition} from "./info";
export const fromjson: FunctionDefinition = {
name: "fromJson",
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData} from "../data/index.js";
import {ExpressionData} from "../data";
export interface FunctionInfo {
name: string;
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData, Kind, StringData} from "../data/index.js";
import {FunctionDefinition} from "./info.js";
import {ExpressionData, Kind, StringData} from "../data";
import {FunctionDefinition} from "./info";
export const join: FunctionDefinition = {
name: "join",
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData} from "../data/index.js";
import {toUpperSpecial} from "../result.js";
import {FunctionDefinition} from "./info.js";
import {BooleanData, ExpressionData} from "../data";
import {toUpperSpecial} from "../result";
import {FunctionDefinition} from "./info";
export const startswith: FunctionDefinition = {
name: "startsWith",
+3 -3
View File
@@ -1,6 +1,6 @@
import {ExpressionData, StringData} from "../data/index.js";
import {replacer} from "../data/replacer.js";
import {FunctionDefinition} from "./info.js";
import {ExpressionData, StringData} from "../data";
import {replacer} from "../data/replacer";
import {FunctionDefinition} from "./info";
export const tojson: FunctionDefinition = {
name: "toJson",
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData} from "./data/index.js";
import {ExpressionData} from "./data";
export class idxHelper {
public readonly str: string | undefined;
+9 -9
View File
@@ -1,9 +1,9 @@
export {Expr} from "./ast.js";
export {complete, CompletionItem} from "./completion.js";
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js";
export * as data from "./data/index.js";
export {ExpressionError, ExpressionEvaluationError} from "./errors.js";
export {Evaluator} from "./evaluator.js";
export {wellKnownFunctions} from "./funcs.js";
export {Lexer, Result} from "./lexer.js";
export {Parser} from "./parser.js";
export {Expr} from "./ast";
export {complete, CompletionItem} from "./completion";
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
export * as data from "./data";
export {ExpressionError, ExpressionEvaluationError} from "./errors";
export {Evaluator} from "./evaluator";
export {wellKnownFunctions} from "./funcs";
export {Lexer, Result} from "./lexer";
export {Parser} from "./parser";
+1 -1
View File
@@ -1,4 +1,4 @@
import {Lexer, Token, TokenType} from "./lexer.js";
import {Lexer, Token, TokenType} from "./lexer";
describe("lexer", () => {
const tests: {
+2 -2
View File
@@ -1,5 +1,5 @@
import {StringData} from "./data/index.js";
import {MAX_EXPRESSION_LENGTH} from "./errors.js";
import {StringData} from "./data";
import {MAX_EXPRESSION_LENGTH} from "./errors";
export enum TokenType {
UNKNOWN,
+6 -17
View File
@@ -1,20 +1,9 @@
import {
Binary,
ContextAccess,
Expr,
FunctionCall,
Grouping,
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";
import {Binary, ContextAccess, Expr, FunctionCall, Grouping, IndexAccess, Literal, Logical, Star, Unary} from "./ast";
import * as data from "./data";
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors";
import {ParseContext, validateFunction} from "./funcs";
import {FunctionInfo} from "./funcs/info";
import {Token, TokenType} from "./lexer";
export class Parser {
private extContexts: Map<string, boolean>;
+2 -2
View File
@@ -1,5 +1,5 @@
import {BooleanData, ExpressionData, NumberData, StringData} from "./data/index.js";
import {coerceTypes, toUpperSpecial} from "./result.js";
import {BooleanData, ExpressionData, NumberData, StringData} from "./data";
import {coerceTypes, toUpperSpecial} from "./result";
describe("coerceTypes", () => {
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 {
switch (d.kind) {
+9 -9
View File
@@ -1,14 +1,14 @@
import * as fs from "fs";
import * as path from "path";
import {Expr} from "./ast.js";
import * as data from "./data/index.js";
import {kindStr} from "./data/expressiondata.js";
import {replacer} from "./data/replacer.js";
import {reviver} from "./data/reviver.js";
import {ExpressionError} from "./errors.js";
import {Evaluator} from "./evaluator.js";
import {Lexer, Result} from "./lexer.js";
import {Parser} from "./parser.js";
import {Expr} from "./ast";
import * as data from "./data";
import {kindStr} from "./data/expressiondata";
import {replacer} from "./data/replacer";
import {reviver} from "./data/reviver";
import {ExpressionError} from "./errors";
import {Evaluator} from "./evaluator";
import {Lexer, Result} from "./lexer";
import {Parser} from "./parser";
interface TestResult {
value: data.ExpressionData;
+1 -4
View File
@@ -2,12 +2,9 @@
"exclude": ["./src/**/*.test.ts"],
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "./dist",
"skipLibCheck": true
"outDir": "./dist"
}
}
-173
View File
@@ -10,14 +10,6 @@ The [package](https://www.npmjs.com/package/@actions/languageserver) contains Ty
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
### Basic usage using `vscode-languageserver-node`
@@ -100,150 +92,6 @@ const clientOptions: LanguageClientOptions = {
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
```
### 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: git@github.com: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
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
@@ -262,27 +110,6 @@ or to watch for changes
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
```bash
@@ -1,2 +0,0 @@
#!/usr/bin/env node
import "../dist/cli.bundle.cjs";
+9 -16
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.33",
"version": "0.3.3",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -31,43 +31,36 @@
"url": "https://github.com/actions/languageservices"
},
"scripts": {
"build": "tsc --build tsconfig.build.json && npm run build:cli",
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
"build": "tsc --build tsconfig.build.json",
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"prepublishOnly": "npm run build && npm run test",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --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"
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/languageservice": "^0.3.33",
"@actions/workflow-parser": "^0.3.33",
"@octokit/rest": "^21.1.1",
"@actions/languageservice": "^0.3.3",
"@actions/workflow-parser": "^0.3.3",
"@octokit/rest": "^19.0.7",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
"vscode-languageserver-textdocument": "^1.0.7",
"yaml": "^2.1.3"
},
"engines": {
"node": ">= 18"
"node": ">= 16.15"
},
"files": [
"dist/**/*",
"bin/**/*"
"dist/**/*"
],
"devDependencies": {
"@types/jest": "^29.0.3",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"esbuild": "^0.27.1",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
+2 -3
View File
@@ -1,9 +1,8 @@
import {Octokit} from "@octokit/rest";
export function getClient(token: string, userAgent?: string, apiUrl?: string): Octokit {
export function getClient(token: string, userAgent?: string): Octokit {
return new Octokit({
auth: token,
userAgent: userAgent || `GitHub Actions Language Server`,
baseUrl: apiUrl
userAgent: userAgent || `GitHub Actions Language Server`
});
}
+3 -12
View File
@@ -1,4 +1,4 @@
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
@@ -12,8 +12,6 @@ import {
HoverParams,
InitializeParams,
InitializeResult,
InlayHint,
InlayHintParams,
TextDocumentIdentifier,
TextDocumentPositionParams,
TextDocuments,
@@ -53,7 +51,7 @@ export function initConnection(connection: Connection) {
const options = params.initializationOptions as InitializationOptions;
if (options.sessionToken) {
client = getClient(options.sessionToken, options.userAgent, options.gitHubApiUrl);
client = getClient(options.sessionToken, options.userAgent);
}
if (options.repos) {
@@ -74,8 +72,7 @@ export function initConnection(connection: Connection) {
hoverProvider: true,
documentLinkProvider: {
resolveProvider: false
},
inlayHintProvider: true
}
}
};
@@ -161,12 +158,6 @@ export function initConnection(connection: Connection) {
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));
});
});
// Make the text document manager listen on the connection
// for open, change and close text document events
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";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
describe("contextProviders", () => {
const mockCache = new TTLCache();
const mockRepo: RepositoryContext = {
id: 123,
owner: "test-owner",
name: "test-repo",
organizationOwned: true,
workspaceUri: "file:///workspace"
};
const mockWorkflowContext: WorkflowContext = {
uri: "test.yaml",
template: undefined
};
describe("when client is undefined", () => {
it("should return incomplete context for secrets", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should return incomplete context for vars", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should preserve defaultContext and mark as incomplete for secrets", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const defaultContext = new DescriptionDictionary();
defaultContext.add("EXISTING_SECRET", new data.StringData("test"));
const result = await config.getContext("secrets", defaultContext, mockWorkflowContext, Mode.Validation);
expect(result).toBe(defaultContext);
expect((result as DescriptionDictionary).complete).toBe(false);
expect((result as DescriptionDictionary).get("EXISTING_SECRET")).toBeDefined();
});
it("should return undefined for other contexts like steps", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("steps", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeUndefined();
});
});
describe("when both client and repo are undefined", () => {
it("should return incomplete context for secrets", async () => {
const config = contextProviders(undefined, undefined, mockCache);
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should return incomplete context for vars", async () => {
const config = contextProviders(undefined, undefined, mockCache);
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
});
});
+1 -12
View File
@@ -15,18 +15,7 @@ export function contextProviders(
cache: TTLCache
): ContextProviderConfig {
if (!repo || !client) {
// When GitHub client/repo is unavailable, return an incomplete dictionary
// to avoid false "Context access might be invalid" warnings
return {
getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => {
if (name === "secrets" || name === "vars") {
const context = defaultContext || new DescriptionDictionary();
context.complete = false;
return Promise.resolve(context);
}
return Promise.resolve(undefined);
}
};
return {getContext: () => Promise.resolve(undefined)};
}
const getContext = async (
@@ -28,7 +28,6 @@ export async function getSecrets(
}
const eventsConfig = workflowContext?.template?.events;
if (eventsConfig?.workflow_call) {
// Unpredictable secrets may be passed in via a workflow_call trigger
secretsContext.complete = false;
@@ -39,7 +38,6 @@ export async function getSecrets(
}
let environmentName: string | undefined;
if (workflowContext?.job?.environment) {
if (isString(workflowContext.job.environment)) {
environmentName = workflowContext.job.environment.value;
@@ -48,17 +46,10 @@ export async function getSecrets(
if (isString(x.key) && x.key.value === "name") {
if (isString(x.value)) {
environmentName = x.value.value;
} else {
// this means we have a dynamic environment, in those situations we
// want to make sure we skip doing secret validation
secretsContext.complete = false;
}
break;
}
}
} else {
// if the expression is something like environment: ${{ ... }} then we want to skip validation
secretsContext.complete = false;
}
}
@@ -125,7 +116,7 @@ async function getRemoteSecrets(
environmentSecrets:
(environmentName &&
(await cache.get(`${repo.owner}/${repo.name}/secrets/environment/${environmentName}`, undefined, () =>
fetchEnvironmentSecrets(octokit, repo.owner, repo.name, environmentName)
fetchEnvironmentSecrets(octokit, repo.id, environmentName)
))) ||
[],
orgSecrets: await cache.get(`${repo.owner}/secrets`, undefined, () => fetchOrganizationSecrets(octokit, repo))
@@ -151,16 +142,14 @@ async function fetchSecrets(octokit: Octokit, owner: string, name: string): Prom
async function fetchEnvironmentSecrets(
octokit: Octokit,
owner: string,
name: string,
repositoryId: number,
environmentName: string
): Promise<StringData[]> {
try {
return await octokit.paginate(
octokit.actions.listEnvironmentSecrets,
{
owner,
repo: name,
repository_id: repositoryId,
environment_name: environmentName,
per_page: 100
},
@@ -1,4 +1,4 @@
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {data, DescriptionDictionary} from "@actions/expressions";
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
@@ -63,47 +63,6 @@ it("returns default context when job is undefined", async () => {
expect(stepsContext).toEqual(defaultContext);
});
it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
const mock = fetchMock
.sandbox()
.getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata);
const workflowContext = await createWorkflowContext(workflow, "build");
const defaultContext = getDefaultStepsContext(workflowContext);
const stepsContext = await getStepsContext(
new Octokit({
request: {
fetch: mock
}
}),
new TTLCache(),
defaultContext,
workflowContext
);
// Get the step context
const stepContext = stepsContext?.get("cache-primes");
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 () => {
const mock = fetchMock
.sandbox()
@@ -124,34 +83,29 @@ it("adds action outputs", async () => {
);
expect(stepsContext).toBeDefined();
// Create expected outputs dict with complete = false
// (actions can have dynamic outputs beyond what's declared in action.yml)
const expectedOutputs = new DescriptionDictionary({
key: "cache-hit",
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
description: "A boolean value to indicate an exact match was found for the primary key"
});
expectedOutputs.complete = false;
expect(stepsContext).toEqual(
new DescriptionDictionary({
key: "cache-primes",
value: new DescriptionDictionary(
{
key: "outputs",
value: expectedOutputs
value: new DescriptionDictionary({
key: "cache-hit",
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
description: "A boolean value to indicate an exact match was found for the primary key"
})
},
{
key: "conclusion",
value: new data.Null(),
description:
"The result of a completed step after [`continue-on-error`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscontinue-on-error) is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
"The result of a completed step after `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
},
{
key: "outcome",
value: new data.Null(),
description:
"The result of a completed step before [`continue-on-error`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscontinue-on-error) is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
"The result of a completed step before `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
}
)
})
@@ -58,8 +58,6 @@ export async function getStepsContext(
continue;
}
const outputsDict = new DescriptionDictionary();
// Actions can have dynamic outputs beyond what's declared in action.yml
outputsDict.complete = false;
for (const [key, value] of Object.entries(outputs)) {
outputsDict.add(key, new data.StringData(value.description), value.description);
}
@@ -2,10 +2,9 @@ import {data, DescriptionDictionary} from "@actions/expressions";
import {Pair} from "@actions/expressions/data/expressiondata";
import {StringData} from "@actions/expressions/data/index";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {log, warn} from "@actions/languageservice/log";
import {warn} from "@actions/languageservice/log";
import {isMapping, isString} from "@actions/workflow-parser";
import {Octokit} from "@octokit/rest";
import {RequestError} from "@octokit/request-error";
import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "../utils/cache";
@@ -26,8 +25,6 @@ export async function getVariables(
return secretsContext;
}
const variablesContext = defaultContext || new DescriptionDictionary();
let environmentName: string | undefined;
if (workflowContext?.job?.environment) {
if (isString(workflowContext.job.environment)) {
@@ -37,71 +34,58 @@ export async function getVariables(
if (isString(x.key) && x.key.value === "name") {
if (isString(x.value)) {
environmentName = x.value.value;
} else {
// this means we have a dynamic environment, in those situations we want to skip validation
variablesContext.complete = false;
}
break;
}
}
} else {
// if the expression is something like environment: ${{ ... }} then we want to skip validation
variablesContext.complete = false;
}
}
try {
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
const variablesContext = defaultContext || new DescriptionDictionary();
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
// Build combined map of variables
const variablesMap = new Map<
string,
{
key: string;
value: data.StringData;
description?: string;
}
>();
// Build combined map of variables
const variablesMap = new Map<
string,
{
key: string;
value: data.StringData;
description?: string;
}
>();
variables.organizationVariables.forEach(variable =>
variablesMap.set(variable.key.toLowerCase(), {
key: variable.key,
value: new data.StringData(variable.value.coerceString()),
description: `${variable.value.coerceString()} - Organization variable`
})
);
variables.organizationVariables.forEach(variable =>
variablesMap.set(variable.key.toLowerCase(), {
key: variable.key,
value: new data.StringData(variable.value.coerceString()),
description: `${variable.value.coerceString()} - Organization variable`
})
);
// Override org variables with repo variables
variables.repoVariables.forEach(variable =>
variablesMap.set(variable.key.toLowerCase(), {
key: variable.key,
value: new data.StringData(variable.value.coerceString()),
description: `${variable.value.coerceString()} - Repository variable`
})
);
// Override org variables with repo variables
variables.repoVariables.forEach(variable =>
variablesMap.set(variable.key.toLowerCase(), {
key: variable.key,
value: new data.StringData(variable.value.coerceString()),
description: `${variable.value.coerceString()} - Repository variable`
})
);
// Override repo variables with environment veriables (if defined)
variables.environmentVariables.forEach(variable =>
variablesMap.set(variable.key.toLowerCase(), {
key: variable.key,
value: new data.StringData(variable.value.coerceString()),
description: `${variable.value.coerceString()} - Variable for environment \`${environmentName || ""}\``
})
);
// Override repo variables with environment veriables (if defined)
variables.environmentVariables.forEach(variable =>
variablesMap.set(variable.key.toLowerCase(), {
key: variable.key,
value: new data.StringData(variable.value.coerceString()),
description: `${variable.value.coerceString()} - Variable for environment \`${environmentName || ""}\``
})
);
// Sort variables by key and add to context
Array.from(variablesMap.values())
.sort((a, b) => a.key.localeCompare(b.key))
.forEach(variable => variablesContext?.add(variable.key, variable.value, variable.description));
// Sort variables by key and add to context
Array.from(variablesMap.values())
.sort((a, b) => a.key.localeCompare(b.key))
.forEach(variable => variablesContext?.add(variable.key, variable.value, variable.description));
return variablesContext;
} catch (e) {
if (!(e instanceof RequestError)) throw e;
if (e.name == "HttpError" && e.status == 404) {
log("Failure to request variables. Ignore if you're using GitHub Enterprise Server below version 3.8");
return variablesContext;
} else throw e;
}
return variablesContext;
}
export async function getRemoteVariables(
@@ -122,7 +106,7 @@ export async function getRemoteVariables(
environmentVariables:
(environmentName &&
(await cache.get(`${repo.owner}/${repo.name}/vars/environment/${environmentName}`, undefined, () =>
fetchEnvironmentVariables(octokit, repo.owner, repo.name, environmentName)
fetchEnvironmentVariables(octokit, repo.id, environmentName)
))) ||
[],
organizationVariables: await cache.get(`${repo.owner}/vars`, undefined, () =>
@@ -153,16 +137,14 @@ async function fetchVariables(octokit: Octokit, owner: string, name: string): Pr
async function fetchEnvironmentVariables(
octokit: Octokit,
owner: string,
name: string,
repositoryId: number,
environmentName: string
): Promise<Pair[]> {
try {
return await octokit.paginate(
octokit.actions.listEnvironmentVariables,
{
owner: owner,
repo: name,
repository_id: repositoryId,
environment_name: environmentName,
per_page: 100
},
+2 -5
View File
@@ -2,8 +2,8 @@ import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
import {Octokit} from "@octokit/rest";
import path from "path";
import {TTLCache} from "./utils/cache";
import * as vscodeURI from "vscode-uri";
export function getFileProvider(
client: Octokit | undefined,
@@ -31,10 +31,7 @@ export function getFileProvider(
throw new Error("Local file references are not supported with this configuration");
}
const workspaceURI = vscodeURI.URI.parse(workspace);
const refURI = vscodeURI.Utils.joinPath(workspaceURI, ref.path);
const file = await readFile(refURI.toString());
const file = await readFile(path.join(workspace, ref.path));
if (!file) {
throw new Error(`File not found: ${ref.path}`);
}
@@ -23,11 +23,6 @@ export interface InitializationOptions {
* Desired log level
*/
logLevel?: LogLevel;
/**
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
*/
gitHubApiUrl?: string;
}
export interface RepositoryContext {
+7 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.33",
"version": "0.3.3",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -35,27 +35,24 @@
"clean": "rimraf dist",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
"lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'",
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
"prebuild": "npm run minify-json",
"prepublishOnly": "npm run build && npm run test",
"pretest": "npm run minify-json",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"update-webhooks": "npx tsx script/webhooks/index.ts",
"update-webhooks": "ts-node-esm script/webhooks/index.ts",
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.33",
"@actions/workflow-parser": "^0.3.33",
"@actions/expressions": "^0.3.3",
"@actions/workflow-parser": "^0.3.3",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
"vscode-uri": "^3.0.7",
"yaml": "^2.1.1"
},
"engines": {
"node": ">= 18"
"node": ">= 16.15"
},
"files": [
"dist/**/*"
+3 -274
View File
@@ -1,191 +1,12 @@
import {promises as fs} from "fs";
import Webhook from "./webhook.js";
import schemaImport from "rest-api-description/descriptions/api.github.com/dereferenced/api.github.com.deref.json";
import schemaImport from "rest-api-description/descriptions/api.github.com/dereferenced/api.github.com.deref.json" assert {type: "json"};
import {deduplicateWebhooks} from "./deduplicate.js";
const schema = schemaImport as any;
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
const OBJECTS_PATH = "./src/context-providers/events/objects.json";
const ALL_OUTPUT_PATH = "./src/context-providers/events/webhooks.all.json";
const ALL_OBJECTS_PATH = "./src/context-providers/events/objects.all.json";
const DROP_OUTPUT_PATH = "./src/context-providers/events/webhooks.drop.json";
const DROP_OBJECTS_PATH = "./src/context-providers/events/objects.drop.json";
const STRIP_OUTPUT_PATH = "./src/context-providers/events/webhooks.strip.json";
const STRIP_OBJECTS_PATH = "./src/context-providers/events/objects.strip.json";
// Parse --all flag
const generateAll = process.argv.includes("--all");
// Events to drop - not valid workflow triggers (GitHub App or API-only events)
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const DROPPED_EVENTS = new Set([
"branch_protection_configuration",
"code_scanning_alert",
"commit_comment",
"custom_property",
"custom_property_values",
"dependabot_alert",
"deploy_key",
"github_app_authorization",
"installation",
"installation_repositories",
"installation_target",
"marketplace_purchase",
"member",
"membership",
"merge_group",
"meta",
"org_block",
"organization",
"package",
"personal_access_token_request",
"ping",
"repository",
"repository_advisory",
"repository_ruleset",
"secret_scanning_alert",
"secret_scanning_alert_location",
"security_advisory",
"security_and_analysis",
"sponsorship",
"star",
"team",
"team_add"
]);
// Events to keep - valid workflow triggers
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const KEPT_EVENTS = new Set([
"branch_protection_rule",
"check_run",
"check_suite",
"create",
"delete",
"deployment",
"deployment_status",
"discussion",
"discussion_comment",
"fork",
"gollum",
"issue_comment",
"issues",
"label",
"milestone",
"page_build",
"project",
"project_card",
"project_column",
"projects_v2",
"projects_v2_item",
"public",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
"pull_request_review_thread",
"push",
"registry_package",
"release",
"repository_dispatch",
"repository_import",
"repository_vulnerability_alert",
"status",
"watch",
"workflow_dispatch",
"workflow_job",
"workflow_run"
]);
/**
* Fields to strip from the JSON data.
*
* EVENT_ACTION_FIELDS: stripped from each event action object (top level only)
* Example event action object before stripping:
* {
* "description": "This event is triggered when...", // <-- stripped
* "summary": "A brief summary", // <-- stripped
* "availability": ["repository"], // <-- stripped
* "category": "issues", // <-- stripped
* "action": "opened", // kept
* "bodyParameters": [...] // kept
* }
*
* BODY_PARAM_FIELDS: stripped from every bodyParameters object, recursively through childParamsGroups
* Example bodyParameter object before stripping:
* {
* "type": "object", // <-- stripped
* "name": "changes", // kept (used for property names)
* "in": "body", // <-- stripped
* "description": "The changes that were made.", // kept (used for hover docs)
* "isRequired": true, // <-- stripped
* "enum": ["a", "b"], // <-- stripped
* "default": "a", // <-- stripped
* "childParamsGroups": [ // kept (used for nested properties)
* {
* "type": "string", // <-- stripped (recursive)
* "name": "from", // kept
* "isRequired": true // <-- stripped (recursive)
* }
* ]
* }
*/
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category"];
const BODY_PARAM_FIELDS = ["type", "in", "isRequired", "enum", "default"];
/**
* Strip fields from a bodyParameter object and recursively from childParamsGroups.
*/
function stripBodyParam(param: any): any {
if (typeof param !== "object" || param === null) {
return param;
}
const result: any = {};
for (const [key, value] of Object.entries(param)) {
if (BODY_PARAM_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "childParamsGroups" && Array.isArray(value)) {
result[key] = value.map(stripBodyParam);
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from event action data.
*/
function stripEventActionFields(action: any): any {
const result: any = {};
for (const [key, value] of Object.entries(action)) {
if (EVENT_ACTION_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "bodyParameters" && Array.isArray(value)) {
result[key] = value.map((p: any) => (typeof p === "number" ? p : stripBodyParam(p)));
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from all webhooks.
* Structure: { eventName: { actionName: { ...fields } } }
*/
function stripFields(webhooks: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
const result: Record<string, Record<string, any>> = {};
for (const [eventName, actions] of Object.entries(webhooks)) {
result[eventName] = {};
for (const [actionName, actionData] of Object.entries(actions)) {
result[eventName][actionName] = stripEventActionFields(actionData);
}
}
return result;
}
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
if (!rawWebhooks) {
@@ -199,51 +20,11 @@ for (const webhook of Object.values(rawWebhooks)) {
await Promise.all(webhooks.map(webhook => webhook.process()));
// Check for unknown events (not in DROPPED_EVENTS or KEPT_EVENTS)
const unknownEvents: string[] = [];
for (const webhook of webhooks) {
if (!DROPPED_EVENTS.has(webhook.category) && !KEPT_EVENTS.has(webhook.category)) {
if (!unknownEvents.includes(webhook.category)) {
unknownEvents.push(webhook.category);
}
}
}
if (unknownEvents.length > 0) {
console.error("");
console.error("══════════════════════════════════════════════════════════════════");
console.error("ERROR: New webhook event(s) detected!");
console.error("══════════════════════════════════════════════════════════════════");
console.error("");
console.error("The following events are not categorized:");
for (const event of unknownEvents.sort()) {
console.error(` - ${event}`);
}
console.error("");
console.error("Action required:");
console.error(" 1. Check if the event is a valid workflow trigger:");
console.error(
" https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows"
);
console.error("");
console.error(" 2. Add the event to DROPPED_EVENTS or KEPT_EVENTS in:");
console.error(" languageservice/script/webhooks/index.ts");
console.error("");
console.error(" 3. See docs/json-data-files.md for more details.");
console.error("");
process.exit(1);
}
// The category is the name of the webhook
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
for (const webhook of webhooks) {
if (!webhook.action) webhook.action = "default";
// Drop unused events
if (DROPPED_EVENTS.has(webhook.category)) {
continue;
}
if (categorizedWebhooks[webhook.category]) {
categorizedWebhooks[webhook.category][webhook.action] = webhook;
} else {
@@ -252,59 +33,7 @@ for (const webhook of webhooks) {
}
}
// Strip fields before deduplication
const strippedWebhooks = stripFields(categorizedWebhooks);
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
// Deduplicate after dropping and stripping
const objectsArray = deduplicateWebhooks(strippedWebhooks);
// Write optimized output
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, null, 2));
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(strippedWebhooks).length} events)`);
console.log(`Wrote ${OBJECTS_PATH} (${objectsArray.length} objects)`);
// Optionally generate intermediate versions for size comparison
if (generateAll) {
// Helper to deep clone
function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
// Build full webhooks (no drop, no strip) from fresh data
const fullWebhooks: Record<string, Record<string, any>> = {};
for (const webhook of webhooks) {
const w = clone(webhook);
if (!w.action) w.action = "default";
fullWebhooks[w.category] ||= {};
fullWebhooks[w.category][w.action] = w;
}
// Generate all version (no drop, no strip)
const allWebhooks = clone(fullWebhooks);
const allObjects = deduplicateWebhooks(allWebhooks);
await fs.writeFile(ALL_OUTPUT_PATH, JSON.stringify(allWebhooks, null, 2));
await fs.writeFile(ALL_OBJECTS_PATH, JSON.stringify(allObjects, null, 2));
console.log(`Wrote ${ALL_OUTPUT_PATH} (${Object.keys(allWebhooks).length} events)`);
console.log(`Wrote ${ALL_OBJECTS_PATH} (${allObjects.length} objects)`);
// Generate drop-only version (drop events, no strip)
const dropWebhooks = clone(fullWebhooks);
for (const event of DROPPED_EVENTS) {
delete dropWebhooks[event];
}
const dropObjects = deduplicateWebhooks(dropWebhooks);
await fs.writeFile(DROP_OUTPUT_PATH, JSON.stringify(dropWebhooks, null, 2));
await fs.writeFile(DROP_OBJECTS_PATH, JSON.stringify(dropObjects, null, 2));
console.log(`Wrote ${DROP_OUTPUT_PATH} (${Object.keys(dropWebhooks).length} events)`);
console.log(`Wrote ${DROP_OBJECTS_PATH} (${dropObjects.length} objects)`);
// Generate strip-only version (strip fields, no drop)
const stripWebhooks = stripFields(clone(fullWebhooks));
const stripObjects = deduplicateWebhooks(stripWebhooks);
await fs.writeFile(STRIP_OUTPUT_PATH, JSON.stringify(stripWebhooks, null, 2));
await fs.writeFile(STRIP_OBJECTS_PATH, JSON.stringify(stripObjects, null, 2));
console.log(`Wrote ${STRIP_OUTPUT_PATH} (${Object.keys(stripWebhooks).length} events)`);
console.log(`Wrote ${STRIP_OBJECTS_PATH} (${stripObjects.length} objects)`);
}
await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
+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", () => {
it("basic action", () => {
-295
View File
@@ -1,295 +0,0 @@
import {TextDocument} from "vscode-languageserver-textdocument";
import {complete} from "./complete";
import {clearCache} from "./utils/workflow-cache";
beforeEach(() => {
clearCache();
});
describe("complete action files", () => {
function createActionDocument(
content: string,
uri = "file:///test/action.yml"
): [TextDocument, {line: number; character: number}] {
// Parse cursor position and remove the | character
const cursorIndex = content.indexOf("|");
if (cursorIndex === -1) {
throw new Error("No cursor (|) found in content");
}
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
const doc = TextDocument.create(uri, "yaml", 1, newContent);
const position = doc.positionAt(cursorIndex);
return [doc, position];
}
describe("expression completion in composite actions", () => {
it("completes inputs context", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
inputs:
name:
description: The name
greeting:
description: The greeting
default: Hello
runs:
using: composite
steps:
- run: echo "\${{ inputs.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("name");
expect(labels).toContain("greeting");
});
it("completes steps context with prior step IDs", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- id: step1
run: echo "hello"
shell: bash
- id: step2
run: echo "\${{ steps.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("step1");
expect(labels).not.toContain("step2"); // Current step should not be included
});
it("completes step properties", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- id: greet
run: echo "hello"
shell: bash
- run: echo "\${{ steps.greet.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("outputs");
expect(labels).toContain("outcome");
expect(labels).toContain("conclusion");
});
it("does not include steps from after cursor position", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- id: first
run: echo "first"
shell: bash
- run: echo "\${{ steps.| }}"
shell: bash
- id: last
run: echo "last"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("first");
expect(labels).not.toContain("last");
});
it("completes github context in actions", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- run: echo "\${{ github.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("actor");
expect(labels).toContain("repository");
expect(labels).toContain("ref");
});
it("completes runner context in actions", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- run: echo "\${{ runner.| }}"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("os");
expect(labels).toContain("arch");
expect(labels).toContain("temp");
});
});
describe("top-level completions", () => {
it("completes top-level keys", async () => {
const [doc, position] = createActionDocument(`n|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("name");
});
it("completes at empty line", async () => {
const [doc, position] = createActionDocument(`name: My Action
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("description");
expect(labels).toContain("runs");
expect(labels).toContain("inputs");
expect(labels).toContain("outputs");
expect(labels).toContain("branding");
expect(labels).toContain("author");
});
});
describe("runs completions", () => {
it("completes runs.using values", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("composite");
expect(labels).toContain("node20");
expect(labels).toContain("docker");
});
it("completes runs keys", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("using");
});
});
describe("branding completions", () => {
it("completes branding keys", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node20
main: index.js
branding:
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("icon");
expect(labels).toContain("color");
});
it("completes branding color values", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node20
main: index.js
branding:
color: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("blue");
expect(labels).toContain("green");
expect(labels).toContain("red");
});
});
describe("inputs completions", () => {
it("completes input property keys", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
inputs:
my-input:
|
runs:
using: node20
main: index.js`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("description");
expect(labels).toContain("required");
expect(labels).toContain("default");
expect(labels).toContain("deprecationMessage");
});
});
describe("document type routing", () => {
it("routes action.yml to action completion", async () => {
const [doc, position] = createActionDocument(`n|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).toContain("name");
// Should NOT contain workflow-specific keys
expect(labels).not.toContain("on");
expect(labels).not.toContain("jobs");
});
it("includes descriptions from schema for completion items", async () => {
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const authorCompletion = completions.find(c => c.label === "author");
expect(authorCompletion).toBeDefined();
expect(authorCompletion?.documentation).toBeDefined();
expect((authorCompletion?.documentation as {value: string})?.value).toContain("author");
});
it("includes descriptions for branding completion", async () => {
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const brandingCompletion = completions.find(c => c.label === "branding");
expect(brandingCompletion).toBeDefined();
expect(brandingCompletion?.documentation).toBeDefined();
expect((brandingCompletion?.documentation as {value: string})?.value).toContain("branding");
});
it("falls back to type description when property has no description", async () => {
// `inputs` uses shorthand form in schema: "inputs": "inputs-strict"
// So the property has no description, but the type `inputs-strict` does
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
const completions = await complete(doc, position);
const inputsCompletion = completions.find(c => c.label === "inputs");
expect(inputsCompletion).toBeDefined();
expect(inputsCompletion?.documentation).toBeDefined();
expect((inputsCompletion?.documentation as {value: string})?.value).toContain("Input parameters");
});
it("does not route workflow files to action completion", async () => {
const doc = TextDocument.create("file:///repo/.github/workflows/ci.yml", "yaml", 1, `o`);
const completions = await complete(doc, {line: 0, character: 1});
const labels = completions.map(c => c.label);
expect(labels).toContain("on");
expect(labels).toContain("jobs");
});
});
});
@@ -1,13 +1,13 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {data, DescriptionDictionary} from "@actions/expressions";
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
import {complete, getExpressionInput} from "./complete.js";
import {ContextProviderConfig} from "./context-providers/config.js";
import {registerLogger} from "./log.js";
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
import {TestLogger} from "./test-utils/logger.js";
import {testFileProvider} from "./test-utils/test-file-provider.js";
import {clearCache} from "./utils/workflow-cache.js";
import {complete, getExpressionInput} from "./complete";
import {ContextProviderConfig} from "./context-providers/config";
import {registerLogger} from "./log";
import {getPositionFromCursor} from "./test-utils/cursor-position";
import {TestLogger} from "./test-utils/logger";
import {testFileProvider} from "./test-utils/test-file-provider";
import {clearCache} from "./utils/workflow-cache";
const contextProviderConfig: ContextProviderConfig = {
getContext: (context: string) => {
@@ -100,7 +100,7 @@ describe("expressions", () => {
label: "api_url",
documentation: {
kind: "markdown",
value: "The URL of the GitHub REST API."
value: "The URL of the GitHub Actions REST API."
},
kind: CompletionItemKind.Variable
});
@@ -299,16 +299,7 @@ jobs:
"on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([
"arch",
"debug",
"environment",
"name",
"os",
"temp",
"tool_cache",
"workspace"
]);
expect(result.map(x => x.label)).toEqual(["arch", "name", "os", "temp", "tool_cache"]);
});
describe("job if", () => {
@@ -870,7 +861,7 @@ jobs:
});
describe("strategy context", () => {
it("strategy is suggested even when no strategy defined", async () => {
it("strategy is not suggested when outside of a matrix job", async () => {
const input = `
on: push
@@ -884,7 +875,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toContain("strategy");
expect(result.map(x => x.label)).not.toContain("strategy");
});
it("strategy is suggested within a matrix job", async () => {
@@ -931,7 +922,7 @@ jobs:
});
describe("matrix context", () => {
it("matrix is suggested even when no strategy defined", async () => {
it("matrix is not suggested when outside of a matrix job", async () => {
const input = `
on: push
@@ -945,7 +936,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toContain("matrix");
expect(result.map(x => x.label)).not.toContain("strategy");
});
it("matrix is suggested within a matrix job", async () => {
@@ -1110,7 +1101,7 @@ jobs:
`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
expect(result.map(x => x.label)).toEqual(["container", "services", "status"]);
});
it("job context is suggested within a job output", async () => {
@@ -1132,12 +1123,10 @@ jobs:
"github",
"inputs",
"job",
"matrix",
"needs",
"runner",
"secrets",
"steps",
"strategy",
"vars",
"contains",
"endsWith",
@@ -1279,7 +1268,7 @@ jobs:
on: push
jobs:
a:
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
uses: ./reusable-workflow-with-outputs.yaml
b:
needs: [a]
runs-on: ubuntu-latest
@@ -1,169 +0,0 @@
import {complete} from "./complete.js";
import {TextDocument} from "vscode-languageserver-textdocument";
import {clearCache} from "./utils/workflow-cache.js";
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
beforeEach(() => {
clearCache();
});
describe("Issue #81 - multi-line if expression completion", () => {
it("should complete in block scalar if with | (exact position)", async () => {
// Exact reproduction from issue - cursor after "github." in block scalar
const input = `on: push
jobs:
build:
if: |
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 5 (0-indexed) = " github.", character 13 = after the dot
const pos = {line: 5, character: 13};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
expect(result.map(x => x.label)).toContain("actor");
});
it("should complete in block scalar if with > (exact position)", async () => {
const input = `on: push
jobs:
build:
if: >
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
const pos = {line: 5, character: 13};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete in block scalar with multiple lines", async () => {
const input = `on: push
jobs:
build:
if: |
github.event_name == 'push' &&
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
// Skip 1 to skip the `|` block scalar indicator (same character as cursor marker)
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete step if in block scalar", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo
if: |
github.
`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 7 = " github.", character 15 = after the dot (8 spaces + 7 chars)
const pos = {line: 7, character: 15};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete in block scalar with ${{ expression markers", async () => {
// This case works because transform() skips lines with ${{
// Note: Using explicit position because | appears in multiple places (block scalar, ||, cursor)
const input = `on: push
jobs:
build:
if: |
\${{
github.ref == 'refs/heads/main' ||
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 6 = " github." = 8 spaces + 7 chars = 15 chars, cursor after dot is at char 15
const pos = {line: 6, character: 15};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("ref");
expect(result.map(x => x.label)).toContain("ref_name");
});
});
describe("Edge cases for getOffsetInContent", () => {
it("should complete in single-line if (not block scalar)", async () => {
const input = `on: push
jobs:
build:
if: github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete on third content line of block scalar", async () => {
const input = `on: push
jobs:
build:
if: |
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete when block scalar has empty first line", async () => {
const input = `on: push
jobs:
build:
if: |
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
});
@@ -1,8 +1,8 @@
import {CompletionItem, MarkupContent} from "vscode-languageserver-types";
import {complete} from "./complete.js";
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
import {testFileProvider} from "./test-utils/test-file-provider.js";
import {clearCache} from "./utils/workflow-cache.js";
import {complete} from "./complete";
import {getPositionFromCursor} from "./test-utils/cursor-position";
import {testFileProvider} from "./test-utils/test-file-provider";
import {clearCache} from "./utils/workflow-cache";
function mapResult(result: CompletionItem[]) {
return result.map(x => {
@@ -21,7 +21,7 @@ on: push
jobs:
build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
uses: ./reusable-workflow-with-inputs.yaml
with:
|
`;
@@ -49,7 +49,7 @@ on: push
jobs:
build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
uses: ./reusable-workflow-with-inputs.yaml
with:
username: monalisa
|
@@ -74,7 +74,7 @@ on: push
jobs:
build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
uses: ./reusable-workflow-with-inputs.yaml
secrets:
|
`;
@@ -102,7 +102,7 @@ on: push
jobs:
build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
uses: ./reusable-workflow-with-inputs.yaml
secrets: |
`;
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
@@ -117,7 +117,7 @@ on: push
jobs:
build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
uses: ./reusable-workflow-with-inputs.yaml
secrets:
envPAT: "myPAT"
|
+34 -444
View File
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {MarkupContent, TextEdit} from "vscode-languageserver-types";
import {complete} from "./complete.js";
import {registerLogger} from "./log.js";
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
import {TestLogger} from "./test-utils/logger.js";
import {clearCache} from "./utils/workflow-cache.js";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
import {complete} from "./complete";
import {registerLogger} from "./log";
import {getPositionFromCursor} from "./test-utils/cursor-position";
import {TestLogger} from "./test-utils/logger";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
import {clearCache} from "./utils/workflow-cache";
registerLogger(new TestLogger());
@@ -19,12 +19,9 @@ describe("completion", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
expect(result.length).toEqual(14);
expect(result.length).toEqual(12);
const labels = result.map(x => x.label);
expect(labels).toContain("macos-latest");
expect(labels).toContain("(switch to list)");
expect(labels).toContain("(switch to mapping)");
});
it("needs", async () => {
@@ -47,7 +44,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(13);
expect(result.length).toEqual(8);
expect(result[0].label).toEqual("concurrency");
});
@@ -73,7 +70,7 @@ jobs:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(30);
expect(result.length).toEqual(20);
});
it("string definition completion in sequence", async () => {
@@ -98,7 +95,6 @@ jobs:
release:
types: |`;
const result = await complete(...getPositionFromCursor(input));
// Expect string values plus escape hatch to switch to list form
expect(result.map(x => x.label)).toEqual([
"created",
"deleted",
@@ -106,8 +102,7 @@ jobs:
"prereleased",
"published",
"released",
"unpublished",
"(switch to list)"
"unpublished"
]);
});
@@ -195,11 +190,8 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
expect(result).not.toBeUndefined();
// Custom value plus escape hatches for list and full syntax
expect(result.length).toEqual(3);
expect(result.length).toEqual(1);
expect(result[0].label).toEqual("my-custom-label");
expect(result.map(x => x.label)).toContain("(switch to list)");
expect(result.map(x => x.label)).toContain("(switch to mapping)");
});
it("custom value providers for sequences", async () => {
@@ -220,9 +212,7 @@ jobs:
expect(result[0].label).toEqual("my-custom-label");
});
it("does not show mapping keys or parent sibling keys in Key mode", async () => {
// At `container: |`, the scalar form is a string with no constants.
// Mapping keys should NOT be shown inline - but escape hatch to full syntax IS shown.
it("does not show parent mapping sibling keys", async () => {
const input = `on: push
jobs:
build:
@@ -230,21 +220,20 @@ jobs:
runs-on: ubuntu-latest`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Only escape hatch to full syntax (container has mapping form but no sequence)
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
expect(result.length).toEqual(6);
// Should not contain other top-level job keys like `if` and `runs-on`
expect(result.map(x => x.label)).not.toContain("if");
expect(result.map(x => x.label)).not.toContain("runs-on");
});
it("does not show mapping keys in Key mode when structure is uncommitted", async () => {
// At `concurrency: |`, user is in Key mode but hasn't committed to a structure.
// The scalar form is a string with no constants, so no scalar completions.
// But escape hatch to full syntax IS shown as a way out.
it("shows mapping keys within a new map ", async () => {
const input = `on: push
jobs:
build:
concurrency: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
});
it("job key", async () => {
@@ -254,7 +243,7 @@ jobs:
runs-|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(30);
expect(result).toHaveLength(20);
});
it("job key with comment afterwards", async () => {
@@ -265,7 +254,7 @@ jobs:
#`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(30);
expect(result).toHaveLength(20);
});
it("job key with other values afterwards", async () => {
@@ -277,10 +266,7 @@ jobs:
concurrency: 'group-name'`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Verify we get job-level completions, but concurrency is already present so excluded
expect(result.length).toBeGreaterThan(20);
expect(result.some(x => x.label === "runs-on")).toBe(true);
expect(result.some(x => x.label === "concurrency")).toBe(false);
expect(result).toHaveLength(19);
});
it("step key without space after colon", async () => {
@@ -349,9 +335,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
// Verify we get job-level completions including runs-on variants
expect(result.length).toBeGreaterThan(20);
expect(result.some(x => x.label === "steps")).toBe(true);
expect(result).toHaveLength(16);
});
it("complete from behind a colon will replace it", async () => {
@@ -364,8 +348,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
// Verify we get job-level completions
expect(result.length).toBeGreaterThan(20);
expect(result).toHaveLength(16);
const textEdit = result[0].textEdit as TextEdit;
expect(textEdit.range).toEqual({
start: {line: 5, character: 4},
@@ -423,7 +406,7 @@ jobs:
expect(result.map(e => e.label)).toContain("runs-on");
const textEdit = result.filter(e => e.label === "runs-on")[0].textEdit as TextEdit;
expect(textEdit.newText).toEqual("runs-on: ");
expect(textEdit.newText).toEqual("runs-on");
expect(textEdit.range).toEqual({
start: {line: 3, character: 4},
end: {line: 3, character: 10}
@@ -438,7 +421,7 @@ jobs:
expect(result.map(e => e.label)).toContain("runs-on");
const textEdit = result.filter(e => e.label === "runs-on")[0].textEdit as TextEdit;
expect(textEdit.newText).toEqual("runs-on: ");
expect(textEdit.newText).toEqual("runs-on");
expect(textEdit.range).toEqual({
start: {line: 3, character: 4},
end: {line: 3, character: 4}
@@ -464,9 +447,8 @@ jobs:
"timeout-minutes: "
]);
// One-of (scalar variant)
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]);
});
it("custom indentation", async () => {
@@ -488,411 +470,19 @@ jobs:
"timeout-minutes: "
]);
// One-of (scalar variant)
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
// One-of
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]);
});
});
it("does not show mapping keys in Key mode for one-of with mapping variant", async () => {
// At `concurrency: |`, mapping keys should NOT be shown.
// Users who want the mapping form should use `concurrency (full syntax)` at parent level.
it("adds a new line and indentation for mapping keys", async () => {
const input = "concurrency: |";
const result = await complete(...getPositionFromCursor(input));
expect(result.filter(x => x.label === "cancel-in-progress")).toEqual([]);
expect(result.filter(x => x.label === "group")).toEqual([]);
});
it("does not add new line if no key in line", async () => {
const input = "run-n|";
const result = await complete(...getPositionFromCursor(input));
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
});
it("does not show mapping keys when user has started typing a scalar value", async () => {
// User typed `workflow_dispatch: in` - they've committed to a scalar value
// Should not show mapping keys like `inputs`
const input = "on:\n workflow_dispatch: in|";
const result = await complete(...getPositionFromCursor(input));
// No mapping keys should be shown since user started typing a scalar
expect(result.filter(x => x.label === "inputs")).toEqual([]);
});
it("adds : for one-of", async () => {
const input = "on:\n check_run:\n ty|";
const result = await complete(...getPositionFromCursor(input));
// Scalar variant inserts "types: "
const scalarVariant = result.find(x => x.label === "types" && x.labelDetails === undefined);
expect(scalarVariant?.textEdit?.newText).toEqual("types: ");
});
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
// User typed `check_run: ty` - they've committed to scalar form
// The only valid value for check_run scalar is null, so no completions
const input = "on:\n check_run: ty|";
const result = await complete(...getPositionFromCursor(input));
// check_run's scalar form only accepts null, so typing anything should show no completions
// (we don't show mapping keys like `types` anymore - user should use check_run with detail "full syntax" instead)
expect(result.filter(x => x.label === "types")).toEqual([]);
});
it("shows only scalar options for one-of in Key mode when user hasn't committed to a type", async () => {
// At `permissions: |` user hasn't typed anything yet - show only scalar options
// Mapping keys are NOT shown because they would require a newline
// Users who want the mapping form can use `permissions (full syntax)` at the parent level
const input = "on: push\npermissions: |";
const result = await complete(...getPositionFromCursor(input));
// String values (read-all, write-all) should be available
expect(result.filter(x => x.label === "read-all").map(x => x.textEdit?.newText)).toEqual(["read-all"]);
expect(result.filter(x => x.label === "write-all").map(x => x.textEdit?.newText)).toEqual(["write-all"]);
// Mapping keys should NOT be shown - they require a newline which is confusing inline
expect(result.filter(x => x.label === "actions")).toEqual([]);
expect(result.filter(x => x.label === "contents")).toEqual([]);
});
it("filters to scalar options when user has started typing a scalar", async () => {
// User typed `permissions: r` - they've committed to scalar form
const input = "on: push\npermissions: r|";
const result = await complete(...getPositionFromCursor(input));
// Only scalar values should be shown (filtering on 'r')
expect(result.some(x => x.label === "read-all")).toBe(true);
// Mapping keys should NOT be shown
expect(result.filter(x => x.label === "actions")).toEqual([]);
expect(result.filter(x => x.label === "contents")).toEqual([]);
});
it("shows both simple and full syntax for null+mapping one-of", async () => {
// check_run is a one-of: [null, mapping]. Show both:
// - check_run (simple, just the key with colon)
// - check_run with detail "full syntax" (ready to add mapping keys)
const input = "on:\n |";
const result = await complete(...getPositionFromCursor(input));
// Should have both check_run (scalar) and check_run with detail "full syntax"
const checkRunVariants = result.filter(x => x.label === "check_run");
expect(checkRunVariants.some(x => x.labelDetails === undefined)).toBe(true);
expect(checkRunVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
});
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
// runs-on is a one-of: [string, sequence, mapping]
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
// Should have runs-on (scalar), runs-on with detail "list", and runs-on with detail "full syntax"
const runsOnVariants = result.filter(x => x.label === "runs-on");
expect(runsOnVariants.length).toBe(3);
expect(runsOnVariants.some(x => x.labelDetails === undefined)).toBe(true);
expect(runsOnVariants.some(x => x.labelDetails?.description === "list")).toBe(true);
expect(runsOnVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
});
it("generates correct insertText for one-of variants in parent mode", async () => {
// runs-on is a one-of: [string, sequence, mapping]
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: just key with colon and space
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.textEdit?.newText).toEqual("runs-on: ");
// Sequence: key with colon, newline, and list item
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.textEdit?.newText).toEqual(
"runs-on:\n - "
);
// Mapping: key with colon, newline, and indentation for nested keys
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.textEdit?.newText).toEqual(
"runs-on:\n "
);
});
it("generates correct insertText for one-of variants in parent mode", async () => {
// concurrency is a one-of: [string, mapping] - testing parent mode (inside mapping)
// At `concurrency:\n |`, user HAS committed to mapping structure, so mapping keys are shown
const input = "concurrency:\n |";
const result = await complete(...getPositionFromCursor(input));
// In parent mode: just key + colon + space (no leading newline)
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("group: ");
// Boolean in parent mode (cancel-in-progress): key + colon + space
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("cancel-in-progress: ");
});
it("uses sortText for ordering qualified one-of variants", async () => {
// runs-on has multiple structural types, so variants need sorting
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
const runsOnVariants = result.filter(x => x.label === "runs-on");
// Scalar: no sortText needed (sorts naturally first)
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.sortText).toBeUndefined();
// Sequence and mapping: sortText controls ordering
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.sortText).toEqual("runs-on 1");
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.sortText).toEqual("runs-on 2");
});
it("scalar event completion inserts inline without newline", async () => {
// At `on: |` user is completing the value for 'on' key
// Scalar events like `push`, `check_run` should insert inline
const input = "on: |";
const result = await complete(...getPositionFromCursor(input));
// Scalar forms should NOT have newline - they insert inline
const push = result.find(x => x.label === "push");
expect(push?.textEdit?.newText).toEqual("push");
const checkRun = result.find(x => x.label === "check_run" && x.labelDetails === undefined);
expect(checkRun?.textEdit?.newText).toEqual("check_run");
// Full syntax form should NOT be shown in Key mode - it requires a newline
// which is confusing when typing inline. Users who want the mapping form
// can use `on (full syntax)` at the parent level.
expect(result.find(x => x.label === "check_run" && x.labelDetails?.description === "full syntax")).toBeUndefined();
});
it("filters to sequence options when user has started a sequence", async () => {
// User started a sequence with `- ` syntax - they've committed to sequence form
const input = `on: push
jobs:
build:
runs-on:
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels (sequence item values)
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
// Should NOT show mapping keys like `group` or `labels` (those are for full syntax)
expect(result.filter(x => x.label === "group")).toEqual([]);
expect(result.filter(x => x.label === "labels")).toEqual([]);
});
describe("escape hatch completions", () => {
it("runs-on shows switch to list and full syntax", async () => {
const input = `on: push
jobs:
build:
runs-on: |`;
const result = await complete(...getPositionFromCursor(input));
// Should have escape hatches at the end
const switchToList = result.find(x => x.label === "(switch to list)");
const switchToFull = result.find(x => x.label === "(switch to mapping)");
expect(switchToList).toBeDefined();
expect(switchToFull).toBeDefined();
// Escape hatches should sort last
expect(switchToList!.sortText).toEqual("zzz_switch_1");
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
// Escape hatches should have textEdit at cursor position (for VS Code filtering compatibility)
const listEdit = switchToList!.textEdit as TextEdit;
const fullEdit = switchToFull!.textEdit as TextEdit;
// Main textEdit inserts newline and indented content at cursor position
expect(listEdit.newText).toEqual("\n - ");
expect(fullEdit.newText).toEqual("\n ");
// TextEdit range should be at cursor position (empty range)
expect(listEdit.range.start).toEqual({line: 3, character: 13});
expect(listEdit.range.end).toEqual({line: 3, character: 13});
expect(fullEdit.range.start).toEqual({line: 3, character: 13});
expect(fullEdit.range.end).toEqual({line: 3, character: 13});
// additionalTextEdits should clean up the key portion
expect(switchToList!.additionalTextEdits).toHaveLength(1);
expect(switchToList!.additionalTextEdits![0].range.start).toEqual({line: 3, character: 4});
expect(switchToList!.additionalTextEdits![0].range.end).toEqual({line: 3, character: 13});
expect(switchToList!.additionalTextEdits![0].newText).toEqual("runs-on:");
expect(switchToFull!.additionalTextEdits).toHaveLength(1);
expect(switchToFull!.additionalTextEdits![0].newText).toEqual("runs-on:");
});
it("permissions shows only switch to full syntax (no sequence form)", async () => {
const input = `on: push
permissions: |`;
const result = await complete(...getPositionFromCursor(input));
// Should have full syntax escape hatch but NOT list (permissions has no sequence form)
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
});
it("escape hatches are not shown when value is non-empty", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-|`;
const result = await complete(...getPositionFromCursor(input));
// User has started typing a scalar value, no escape hatches
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches are not shown when inside a sequence", async () => {
const input = `on: push
jobs:
build:
runs-on:
- |`;
const result = await complete(...getPositionFromCursor(input));
// User is already in sequence form, no escape hatches
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches are not shown when inside a mapping", async () => {
const input = `on: push
jobs:
build:
runs-on:
group: |`;
const result = await complete(...getPositionFromCursor(input));
// User is in mapping form completing a value, no escape hatches for the parent
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
});
it("escape hatches ARE shown even when no scalar completions exist", async () => {
// concurrency: | has no scalar constants, but escape hatch provides a way out
const input = `on: push
jobs:
build:
concurrency: |`;
const result = await complete(...getPositionFromCursor(input));
// Escape hatch to mapping should be available even with no scalar completions
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
});
it("pure mapping type (strategy) shows switch to mapping", async () => {
const input = `on: push
jobs:
build:
strategy: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
});
it("pure sequence type (steps) shows switch to list", async () => {
const input = `on: push
jobs:
build:
steps: |`;
const result = await complete(...getPositionFromCursor(input));
expect(result.some(x => x.label === "(switch to list)")).toBe(true);
});
it("selecting switch to list restructures YAML", async () => {
const input = `on: push
jobs:
build:
runs-on: |`;
const result = await complete(...getPositionFromCursor(input));
const switchToList = result.find(x => x.label === "(switch to list)");
const textEdit = switchToList!.textEdit as TextEdit;
const additionalEdits = switchToList!.additionalTextEdits!;
// Main textEdit inserts newline content at cursor
expect(textEdit.newText).toEqual("\n - ");
// additionalTextEdits replaces "runs-on: " with "runs-on:"
expect(additionalEdits).toHaveLength(1);
expect(additionalEdits[0].newText).toEqual("runs-on:");
// Combined result when applied: "runs-on:\n - "
});
});
describe("runs-on mapping syntax", () => {
it("provides label completions for labels as scalar", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels: |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
expect(result.some(x => x.label === "self-hosted")).toBe(true);
});
it("provides label completions for labels as sequence item", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels:
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should show runner labels
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
expect(result.some(x => x.label === "macos-latest")).toBe(true);
expect(result.some(x => x.label === "self-hosted")).toBe(true);
});
it("excludes already used labels in sequence", async () => {
const input = `on: push
jobs:
build:
runs-on:
labels:
- ubuntu-latest
- |`;
const result = await complete(...getPositionFromCursor(input));
// Should NOT show ubuntu-latest since it's already in the list
expect(result.some(x => x.label === "ubuntu-latest")).toBe(false);
// But should show other labels
expect(result.some(x => x.label === "macos-latest")).toBe(true);
});
expect(result.filter(x => x.label === "cancel-in-progress").map(x => x.textEdit?.newText)).toEqual([
"\n cancel-in-progress: "
]);
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
});
});
+58 -387
View File
@@ -1,42 +1,30 @@
import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions";
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config.js";
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
import {ActionContext, getActionContext} from "./context/action-context.js";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
import {validatorFunctions} from "./expression-validation/functions.js";
import {error} from "./log.js";
import {detectDocumentType} from "./utils/document-type.js";
import {isPotentiallyExpression} from "./utils/expression-detection.js";
import {findToken} from "./utils/find-token.js";
import {guessIndentation} from "./utils/indentation-guesser.js";
import {mapRange} from "./utils/range.js";
import {isPlaceholder, transform} from "./utils/transform.js";
import {
getOrConvertActionTemplate,
getOrConvertWorkflowTemplate,
getOrParseAction,
getOrParseWorkflow
} from "./utils/workflow-cache.js";
import {Value, ValueProviderConfig} from "./value-providers/config.js";
import {defaultValueProviders} from "./value-providers/default.js";
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
import {ContextProviderConfig} from "./context-providers/config";
import {getContext, Mode} from "./context-providers/default";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
import {validatorFunctions} from "./expression-validation/functions";
import {error} from "./log";
import {isPotentiallyExpression} from "./utils/expression-detection";
import {findToken} from "./utils/find-token";
import {guessIndentation} from "./utils/indentation-guesser";
import {mapRange} from "./utils/range";
import {getRelCharOffset} from "./utils/rel-char-pos";
import {isPlaceholder, transform} from "./utils/transform";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
import {Value, ValueProviderConfig} from "./value-providers/config";
import {defaultValueProviders} from "./value-providers/default";
import {definitionValues} from "./value-providers/definition";
export function getExpressionInput(input: string, pos: number): string {
// Find start marker around the cursor position
@@ -77,85 +65,43 @@ export async function complete(
content: newDoc.getText()
};
// Determine document type - unknown defaults to workflow (backwards compatibility)
const isAction = detectDocumentType(textDocument.uri) === "action";
// Parse the document
const parsedTemplate = isAction
? getOrParseAction(file, textDocument.uri, true)
: getOrParseWorkflow(file, textDocument.uri, true);
if (!parsedTemplate.value) {
const parsedWorkflow = fetchOrParseWorkflow(file, textDocument.uri, true);
if (!parsedWorkflow.value) {
return [];
}
const schema = isAction ? getActionSchema() : getWorkflowSchema();
const {token, keyToken, parent, path} = findToken(newPos, parsedTemplate.value);
const template = await fetchOrConvertWorkflowTemplate(
parsedWorkflow.context,
parsedWorkflow.value,
textDocument.uri,
config,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion
}
);
// Build context for position-aware completions (e.g., steps.*, needs.*, inputs.*)
let workflowContext: WorkflowContext | undefined;
let actionContext: ActionContext | undefined;
if (isAction) {
const actionTemplate = getOrConvertActionTemplate(
parsedTemplate.context,
parsedTemplate.value,
textDocument.uri,
{errorPolicy: ErrorPolicy.TryConversion},
true
);
actionContext = getActionContext(textDocument.uri, actionTemplate, path);
} else {
const workflowTemplate = await getOrConvertWorkflowTemplate(
parsedTemplate.context,
parsedTemplate.value,
textDocument.uri,
config,
{
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
errorPolicy: ErrorPolicy.TryConversion
},
true
);
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
}
const {token, keyToken, parent, path} = findToken(newPos, parsedWorkflow.value);
const workflowContext = getWorkflowContext(textDocument.uri, template, path);
// Expression completions
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
const allowedContext = token.definitionInfo?.allowedContext || [];
const context = isAction
? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion)
: await getWorkflowExpressionContext(
allowedContext,
config?.contextProviderConfig,
workflowContext,
Mode.Completion
);
// If we are inside an expression, take a different code-path. The workflow parser does not correctly create
// expression nodes for invalid expressions and during editing expressions are invalid most of the time.
if (token) {
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
const allowedContext = token.definitionInfo?.allowedContext || [];
const context = await getContext(allowedContext, config?.contextProviderConfig, workflowContext, Mode.Completion);
return getExpressionCompletionItems(token, context, newPos);
return getExpressionCompletionItems(token, context, newPos);
}
}
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
const indentString = " ".repeat(indentation.tabSize);
// YAML key/value completions
const values = await getValues(
token,
keyToken,
parent,
config?.valueProviderConfig,
workflowContext,
indentString,
schema
);
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
values.push(...escapeHatches);
// Figure out what text to replace when the user picks a completion.
// For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`.
let replaceRange: Range | undefined;
if (token?.range) {
// Prefer the token's range since it accounts for YAML syntax like quotes
replaceRange = mapRange(token.range);
} else if (!token) {
// Not a valid token, create a range from the current position
@@ -178,63 +124,30 @@ export async function complete(
}
}
// Convert values to LSP CompletionItems
return values.map(value => {
const newText = value.insertText || value.label;
// Escape hatches provide their own textEdit to restructure the YAML
let textEdit: TextEdit;
if (value.textEdit) {
textEdit = TextEdit.replace(value.textEdit.range, value.textEdit.newText);
} else if (replaceRange) {
textEdit = TextEdit.replace(replaceRange, newText);
} else {
textEdit = TextEdit.insert(position, newText);
}
// Convert additionalTextEdits if present
let additionalTextEdits: TextEdit[] | undefined;
if (value.additionalTextEdits) {
additionalTextEdits = value.additionalTextEdits.map(edit => TextEdit.replace(edit.range, edit.newText));
}
const item: CompletionItem = {
label: value.label,
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
filterText: value.filterText,
sortText: value.sortText,
documentation: value.description && {
kind: "markdown",
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit,
additionalTextEdits
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
};
return item;
});
}
/**
* Retrieves completion values for a token based on value providers and definitions.
*
* This function determines which values to suggest for auto-completion by:
* 1. First checking for custom value providers configured for the token's definition key
* 2. Then checking for default value providers for the token's definition key
* 3. Finally falling back to values derived from the token's schema definition
*
* The results are filtered to exclude duplicates (e.g., keys already defined in a mapping
* or values already present in a sequence) and sorted alphabetically.
*/
async function getValues(
token: TemplateToken | null,
keyToken: TemplateToken | null,
parent: TemplateToken | null,
valueProviderConfig: ValueProviderConfig | undefined,
workflowContext: WorkflowContext | undefined,
indentation: string,
schema: TemplateSchema
workflowContext: WorkflowContext,
indentation: string
): Promise<Value[]> {
if (!parent) {
return [];
@@ -245,23 +158,20 @@ async function getValues(
// Use the value providers from the parent if the current key is null
const valueProviderToken = keyToken || parent;
// Value providers require workflow context - only use them for workflows
if (workflowContext) {
const customValueProvider =
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
if (customValueProvider) {
const customValues = await customValueProvider.get(workflowContext, existingValues);
if (customValues) {
return filterAndSortCompletionOptions(customValues, existingValues);
}
const customValueProvider =
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
if (customValueProvider) {
const customValues = await customValueProvider.get(workflowContext, existingValues);
if (customValues) {
return filterAndSortCompletionOptions(customValues, existingValues);
}
}
const defaultValueProvider =
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
if (defaultValueProvider) {
const values = await defaultValueProvider.get(workflowContext, existingValues);
return filterAndSortCompletionOptions(values, existingValues);
}
const defaultValueProvider =
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
if (defaultValueProvider) {
const values = await defaultValueProvider.get(workflowContext, existingValues);
return filterAndSortCompletionOptions(values, existingValues);
}
// Use the definition if there are no value providers
@@ -270,202 +180,10 @@ async function getValues(
return [];
}
// When a schema allows multiple formats (e.g., `runs-on` can be a string OR a mapping),
// only suggest completions that match what the user has already started typing.
// For example, if they've started a mapping, don't suggest string values.
const tokenStructure = getTokenStructure(token);
const values = definitionValues(
def,
indentation,
keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent,
tokenStructure,
schema
);
const values = definitionValues(def, indentation);
return filterAndSortCompletionOptions(values, existingValues);
}
/**
* Determines what YAML structure the user has committed to, if any.
*
* Returns:
* - "mapping" if the user has started a key-value structure (e.g., `runs-on:\n group: |`)
* - "sequence" if the user has started a list (e.g., `runs-on:\n - |`)
* - "scalar" if the user has started typing a plain value (e.g., `runs-on: ubuntu-|`)
* - undefined if the user hasn't committed yet (e.g., `runs-on: |` with nothing typed)
*/
function getTokenStructure(token: TemplateToken | null): TokenStructure {
if (!token) {
return undefined;
}
switch (token.templateTokenType) {
case TokenType.Mapping:
return "mapping";
case TokenType.Sequence:
return "sequence";
case TokenType.Null:
// Null means `key: ` with nothing - user hasn't committed to a type yet
return undefined;
case TokenType.String: {
// Empty string means `key: |` - user hasn't committed yet
// Non-empty string means user has started typing a scalar value
const stringToken = token.assertString("getTokenStructure expected string token");
if (stringToken.value === "") {
return undefined;
}
return "scalar";
}
case TokenType.Boolean:
case TokenType.Number:
return "scalar";
default:
return undefined;
}
}
/**
* Generates escape hatch completions that allow switching from scalar form to
* alternative structural forms (sequence or mapping) when the value is empty.
*
* For example, at `runs-on: |`, this adds "(switch to list)" and "(switch to full syntax)"
* completions that restructure the YAML to `runs-on:\n - |` or `runs-on:\n |`.
*
* Only shown when:
* - Completing in value position (keyToken exists)
* - Value is empty (user hasn't committed to a structure yet)
* - Definition allows sequence or mapping structure
*/
function getEscapeHatchCompletions(
token: TemplateToken | null,
keyToken: TemplateToken | null,
indentation: string,
position: Position,
schema: TemplateSchema
): Value[] {
// Only show escape hatches when value is empty
const tokenStructure = getTokenStructure(token);
if (tokenStructure !== undefined) {
return [];
}
// Need a key token with a definition
if (!keyToken?.definition) {
return [];
}
// Determine which structural types are available from the definition
const def = keyToken.definition;
const buckets = {
sequence: false,
mapping: false
};
if (def instanceof OneOfDefinition) {
// OneOf: check each variant
for (const variantKey of def.oneOf) {
const variantDef = schema.definitions[variantKey];
if (variantDef) {
switch (variantDef.definitionType) {
case DefinitionType.Sequence:
buckets.sequence = true;
break;
case DefinitionType.Mapping:
buckets.mapping = true;
break;
}
}
}
} else {
// Single definition type
switch (def.definitionType) {
case DefinitionType.Sequence:
buckets.sequence = true;
break;
case DefinitionType.Mapping:
buckets.mapping = true;
break;
}
}
const results: Value[] = [];
const keyName = isString(keyToken) ? keyToken.value : "";
const keyRange = keyToken.range;
if (!keyRange || !keyName) {
return [];
}
// For VS Code compatibility, we use a cursor-position range for the main textEdit
// and additionalTextEdits to clean up the key portion. This prevents VS Code from
// filtering out escape hatches based on the key text (e.g., "runs-on: ").
//
// Main textEdit: insert at cursor position (newline + indented content)
// additionalTextEdits: replace "key: " with "key:" (removes trailing space)
const cursorRange = {
start: {line: position.line, character: position.character},
end: {line: position.line, character: position.character}
};
// Range from key start to cursor - used to replace "key: " with "key:" in additionalTextEdits
const keyToCursorRange = {
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
end: {line: position.line, character: position.character}
};
if (buckets.sequence) {
results.push({
label: "(switch to list)",
sortText: "zzz_switch_1",
textEdit: {
range: cursorRange,
newText: `\n${indentation}- `
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
if (buckets.mapping) {
results.push({
label: "(switch to mapping)",
sortText: "zzz_switch_2",
textEdit: {
range: cursorRange,
newText: `\n${indentation}`
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
return results;
}
/**
* Collects values that are already present in the current context, so they can be
* excluded from completion suggestions.
*
* For sequences (lists), returns all existing items. For example, if the user has:
* labels:
* - bug
* - |
* This returns {"bug"} so we don't suggest "bug" again.
*
* For mappings, returns all existing keys. For example, if the user has:
* jobs:
* build:
* runs-on: ubuntu-latest
* |
* This returns {"runs-on"} so we don't suggest "runs-on" again.
*/
export function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
// For incomplete YAML, we may only have a parent token
if (token) {
@@ -520,12 +238,12 @@ function getExpressionCompletionItems(
currentInput = stringToken.source || stringToken.value;
}
const cursorOffset = getOffsetInContent(token.range, currentInput, pos);
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
const relCharOffset = getRelCharOffset(token.range, currentInput, pos);
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
try {
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
mapExpressionCompletionItem(item, currentInput[cursorOffset])
mapExpressionCompletionItem(item, currentInput[relCharOffset])
);
} catch (e) {
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
@@ -535,7 +253,7 @@ function getExpressionCompletionItems(
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
options = options.filter(x => !existingValues?.has(x.label));
options.sort((a, b) => (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label));
options.sort((a, b) => a.label.localeCompare(b.label));
return options;
}
@@ -556,50 +274,3 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
};
}
/**
* Converts a document position to an offset within the token's content string.
*/
function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: Position): number {
const range = mapRange(tokenRange);
if (range.start.line === range.end.line) {
// Single-line example:
// if: github.ref == 'main'
// ^8 ^15 (cursor)
// currentInput = "github.ref == 'main'"
// offset = 15 - 8 = 7
return pos.character - range.start.character;
}
// Multi-line example:
// if: | <- line 3 (range.start.line)
// first line <- line 4, content line 0
// second line <- line 5, content line 1
// github. <- line 6, content line 2, cursor at index 11
// ^11 (cursor)
//
// currentInput = " first line\n second line\n github."
// ^0 ^15 ^32 ^43
// Line index within content.
// From the example:
// lineIndexWithinContent = pos.line - range.start.line - 1
// = 6 - 3 - 1 = 2
const lineIndexWithinContent = pos.line - range.start.line - 1;
// Length of content before current line.
// From the example:
// lengthOfContentBeforeCurrentLine => 14 + 1 = 15 (after first iteration)
// => 31 + 1 = 32 (after second iteration)
let lengthOfContentBeforeCurrentLine = 0;
for (let i = 0; i < lineIndexWithinContent; i++) {
lengthOfContentBeforeCurrentLine = currentInput.indexOf("\n", lengthOfContentBeforeCurrentLine) + 1;
}
// Final offset within content.
// From the example:
// finalOffset = lengthOfContentBeforeCurrentLine + pos.character
// = 32 + 11 = 43
return lengthOfContentBeforeCurrentLine + pos.character;
}
@@ -1,6 +1,6 @@
import {DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context.js";
import {Mode} from "./default.js";
import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default";
export type ContextProviderConfig = {
getContext: (
@@ -1,102 +0,0 @@
import {DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context.js";
import {getWorkflowExpressionContext, Mode} from "./default.js";
describe("getWorkflowExpressionContext", () => {
const emptyWorkflowContext: WorkflowContext = {
uri: "test.yaml",
template: undefined
};
describe("when no contextProviderConfig is provided", () => {
it("should mark secrets context as incomplete", async () => {
const result = await getWorkflowExpressionContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext).toBeDefined();
expect(secretsContext.complete).toBe(false);
});
it("should mark vars context as incomplete", async () => {
const result = await getWorkflowExpressionContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext).toBeDefined();
expect(varsContext.complete).toBe(false);
});
it("should not mark other contexts as incomplete", async () => {
const result = await getWorkflowExpressionContext(
["env", "github"],
undefined,
emptyWorkflowContext,
Mode.Validation
);
const envContext = result.get("env") as DescriptionDictionary;
const githubContext = result.get("github") as DescriptionDictionary;
// These contexts are derived from the workflow file, so they can be complete
expect(envContext).toBeDefined();
expect(envContext.complete).toBe(true);
expect(githubContext).toBeDefined();
expect(githubContext.complete).toBe(true);
});
});
describe("when contextProviderConfig returns a value", () => {
it("should use the provided context for secrets", async () => {
const providedContext = new DescriptionDictionary();
providedContext.complete = true; // Provider fetched from API, so it's complete
const config = {
getContext: () => Promise.resolve(providedContext)
};
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets");
expect(secretsContext).toBe(providedContext);
expect((secretsContext as DescriptionDictionary).complete).toBe(true);
});
it("should use the provided context for vars", async () => {
const providedContext = new DescriptionDictionary();
providedContext.complete = true;
const config = {
getContext: () => Promise.resolve(providedContext)
};
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars");
expect(varsContext).toBe(providedContext);
expect((varsContext as DescriptionDictionary).complete).toBe(true);
});
});
describe("when contextProviderConfig returns undefined", () => {
it("should mark secrets as incomplete", async () => {
const config = {
getContext: () => Promise.resolve(undefined)
};
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext.complete).toBe(false);
});
it("should mark vars as incomplete", async () => {
const config = {
getContext: () => Promise.resolve(undefined)
};
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext.complete).toBe(false);
});
});
});
+53 -192
View File
@@ -1,18 +1,18 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {Kind} from "@actions/expressions/data/expressiondata";
import {ActionContext, getActionInputs, getActionStepIdsBefore} from "../context/action-context.js";
import {WorkflowContext} from "../context/workflow-context.js";
import {ContextProviderConfig} from "./config.js";
import {getDescription, RootContext} from "./descriptions.js";
import {getEnvContext} from "./env.js";
import {getGithubContext} from "./github.js";
import {getInputsContext} from "./inputs.js";
import {getJobContext} from "./job.js";
import {getJobsContext} from "./jobs.js";
import {getMatrixContext} from "./matrix.js";
import {getNeedsContext} from "./needs.js";
import {getSecretsContext} from "./secrets.js";
import {getStepsContext} from "./steps.js";
import {WorkflowContext} from "../context/workflow-context";
import {ContextProviderConfig} from "./config";
import {getDescription, RootContext} from "./descriptions";
import {getEnvContext} from "./env";
import {getGithubContext} from "./github";
import {getInputsContext} from "./inputs";
import {getJobContext} from "./job";
import {getJobsContext} from "./jobs";
import {getMatrixContext} from "./matrix";
import {getNeedsContext} from "./needs";
import {getSecretsContext} from "./secrets";
import {getStepsContext} from "./steps";
import {getStrategyContext} from "./strategy";
// ContextValue is the type of the value returned by a context provider
// Null indicates that the context provider doesn't have any value to provide
@@ -24,37 +24,23 @@ export enum Mode {
Hover
}
/**
* Build expression context for workflow files (e.g., github.*, steps.*, needs.*)
*/
export async function getWorkflowExpressionContext(
export async function getContext(
names: string[],
config: ContextProviderConfig | undefined,
workflowContext: WorkflowContext | undefined,
workflowContext: WorkflowContext,
mode: Mode
): Promise<DescriptionDictionary> {
const context = new DescriptionDictionary();
// All context names are valid - strategy and matrix are always available
// (with default values when no strategy block is defined)
for (const contextName of names) {
const filteredNames = filterContextNames(names, workflowContext);
for (const contextName of filteredNames) {
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
if (value.kind === Kind.Null) {
context.add(contextName, value);
continue;
}
const remoteValue = workflowContext
? await config?.getContext(contextName, value, workflowContext, mode)
: undefined;
if (remoteValue) {
value = remoteValue;
} else if (contextName === "secrets" || contextName === "vars") {
// Without a context provider to fetch remote secrets/vars, we can't know
// what values exist, so mark the context as incomplete to avoid false
// "Context access might be invalid" warnings
value.complete = false;
}
value = (await config?.getContext(contextName, value, workflowContext, mode)) || value;
context.add(contextName, value, getDescription(RootContext, contextName));
}
@@ -62,198 +48,73 @@ export async function getWorkflowExpressionContext(
return context;
}
/**
* Maps context name to its provider (e.g., "steps" -> getStepsContext)
*/
function getDefaultContext(
name: string,
workflowContext: WorkflowContext | undefined,
mode: Mode
): ContextValue | undefined {
function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: Mode): ContextValue | undefined {
switch (name) {
case "env":
return workflowContext ? getEnvContext(workflowContext) : new DescriptionDictionary();
return getEnvContext(workflowContext);
case "github":
return getGithubContext(workflowContext, mode);
case "inputs":
return workflowContext ? getInputsContext(workflowContext) : new DescriptionDictionary();
return getInputsContext(workflowContext);
case "reusableWorkflowJob":
case "job":
return workflowContext ? getJobContext(workflowContext) : new DescriptionDictionary();
return getJobContext(workflowContext);
case "jobs":
return workflowContext ? getJobsContext(workflowContext) : new DescriptionDictionary();
return getJobsContext(workflowContext);
case "matrix":
return workflowContext ? getMatrixContext(workflowContext, mode) : new DescriptionDictionary();
return getMatrixContext(workflowContext, mode);
case "needs":
return workflowContext ? getNeedsContext(workflowContext) : new DescriptionDictionary();
return getNeedsContext(workflowContext);
case "runner":
return getRunnerContext();
return objectToDictionary({
os: "Linux",
arch: "X64",
name: "GitHub Actions 2",
tool_cache: "/opt/hostedtoolcache",
temp: "/home/runner/work/_temp"
});
case "secrets":
return workflowContext ? getSecretsContext(workflowContext, mode) : new DescriptionDictionary();
return getSecretsContext(workflowContext, mode);
case "steps":
return workflowContext ? getStepsContext(workflowContext) : new DescriptionDictionary();
return getStepsContext(workflowContext);
case "strategy":
return getStrategyContext();
return getStrategyContext(workflowContext);
}
return undefined;
}
/**
* Returns the strategy context with default values (fail-fast, job-index, etc.)
*/
function getStrategyContext(): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
return new DescriptionDictionary(
{key: "fail-fast", value: new data.BooleanData(true), description: getDescription("strategy", "fail-fast")},
{key: "job-index", value: new data.NumberData(0), description: getDescription("strategy", "job-index")},
{key: "job-total", value: new data.NumberData(1), description: getDescription("strategy", "job-total")},
{key: "max-parallel", value: new data.NumberData(1), description: getDescription("strategy", "max-parallel")}
);
function objectToDictionary(object: {[key: string]: string}): DescriptionDictionary {
const dictionary = new DescriptionDictionary();
for (const key in object) {
dictionary.add(key, new data.StringData(object[key]));
}
return dictionary;
}
/**
* Returns the runner context with environment info (arch, os, temp, workspace, etc.)
*/
function getRunnerContext(): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
return new DescriptionDictionary(
{key: "arch", value: new data.StringData("X64"), description: getDescription("runner", "arch")},
{key: "debug", value: new data.StringData("1"), description: getDescription("runner", "debug")},
{
key: "environment",
value: new data.StringData("github-hosted"),
description: getDescription("runner", "environment")
},
{key: "name", value: new data.StringData("GitHub Actions 2"), description: getDescription("runner", "name")},
{key: "os", value: new data.StringData("Linux"), description: getDescription("runner", "os")},
{key: "temp", value: new data.StringData("/home/runner/work/_temp"), description: getDescription("runner", "temp")},
{
key: "tool_cache",
value: new data.StringData("/opt/hostedtoolcache"),
description: getDescription("runner", "tool_cache")
},
{
key: "workspace",
value: new data.StringData("/home/runner/work/repo"),
description: getDescription("runner", "workspace")
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
return contextNames.filter(name => {
switch (name) {
case "matrix":
case "strategy":
return hasStrategy(workflowContext);
}
);
return true;
});
}
/**
* Get context for expression completion in action.yml files.
* Actions have a more limited set of contexts available compared to workflows.
*/
export function getActionExpressionContext(
names: string[],
config: ContextProviderConfig | undefined,
actionContext: ActionContext | undefined,
mode: Mode
): DescriptionDictionary {
const context = new DescriptionDictionary();
for (const contextName of names) {
const value = getDefaultActionContext(contextName, actionContext, mode);
if (value) {
context.add(contextName, value, getDescription(RootContext, contextName));
}
}
return context;
}
/**
* Maps context name to its provider for action.yml files (e.g., "inputs" -> getActionInputsContext)
*/
function getDefaultActionContext(
name: string,
actionContext: ActionContext | undefined,
mode: Mode
): ContextValue | undefined {
switch (name) {
case "inputs":
// Return empty dictionary if no context - still allows completion, just without specific input names
return actionContext ? getActionInputsContext(actionContext) : new DescriptionDictionary();
case "steps":
// Return empty dictionary if no context - still allows completion, just without specific step IDs
return actionContext ? getActionStepsContext(actionContext) : new DescriptionDictionary();
case "github":
// Use the same github context but without workflow-specific event info
// Actions inherit the event context from the calling workflow at runtime
return getGithubContext(undefined, mode);
case "runner":
return getRunnerContext();
case "env":
// Actions can access env but we don't have runtime values
return new DescriptionDictionary();
case "job": {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
const jobContext = new DescriptionDictionary();
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
const containerContext = new DescriptionDictionary();
containerContext.add("id", new data.StringData(""), getDescription("job", "container.id"));
containerContext.add("network", new data.StringData(""), getDescription("job", "container.network"));
jobContext.add("container", containerContext, getDescription("job", "container"));
jobContext.add("services", new DescriptionDictionary(), getDescription("job", "services"));
return jobContext;
}
case "strategy":
return getStrategyContext();
case "matrix":
// Actions can access matrix context at runtime
return new DescriptionDictionary();
}
return undefined;
}
/**
* Get inputs context for action files based on defined inputs
*/
function getActionInputsContext(actionContext: ActionContext): DescriptionDictionary {
const dict = new DescriptionDictionary();
const inputs = getActionInputs(actionContext.template);
for (const input of inputs) {
dict.add(input.id, new data.StringData(""), input.description || "");
}
return dict;
}
/**
* Get steps context for composite action files based on step IDs
*/
function getActionStepsContext(actionContext: ActionContext): DescriptionDictionary {
const dict = new DescriptionDictionary();
const stepIds = getActionStepIdsBefore(actionContext);
for (const stepId of stepIds) {
const stepDict = new DescriptionDictionary();
stepDict.add("outputs", new DescriptionDictionary(), getDescription("steps", "outputs"));
stepDict.add("outcome", new data.StringData("success"), getDescription("steps", "outcome"));
stepDict.add("conclusion", new data.StringData("success"), getDescription("steps", "conclusion"));
dict.add(stepId, stepDict, `Step: ${stepId}`);
}
return dict;
function hasStrategy(workflowContext: WorkflowContext): boolean {
return workflowContext.job?.strategy !== undefined || workflowContext.reusableWorkflowJob?.strategy !== undefined;
}
@@ -49,15 +49,15 @@
"description": "Returns `true` when any previous step of a job fails. If you have a chain of dependent jobs, `failure()` returns `true` if any ancestor job fails."
},
"hashFiles": {
"description": "Returns a single hash for the set of files that matches the `path` pattern. You can provide a single `path` pattern or multiple `path` patterns separated by commas. The `path` is relative to the `GITHUB_WORKSPACE` directory and can only include files inside of the `GITHUB_WORKSPACE`. This function calculates an individual SHA-256 hash for each matched file, and then uses those hashes to calculate a final SHA-256 hash for the set of files. If the `path` pattern does not match any files, this returns an empty string. For more information about SHA-256, see \"[SHA-2](https://wikipedia.org/wiki/SHA-2).\"\n\nYou can use pattern matching characters to match file names. Pattern matching is case-insensitive on Windows. For more information about supported pattern matching characters, see \"[Workflow syntax for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet).\""
"description": "Returns a single hash for the set of files that matches the `path` pattern. You can provide a single `path` pattern or multiple `path` patterns separated by commas. The `path` is relative to the `GITHUB_WORKSPACE` directory and can only include files inside of the `GITHUB_WORKSPACE`."
}
},
"github": {
"action": {
"description": "The name of the action currently running, or the [`id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsid) of a step. GitHub removes special characters, and uses the name `__run` when the current step runs a script without an `id`. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name `__run`, and the second script will be named `__run_2`. Similarly, the second invocation of `actions/checkout` will be `actionscheckout2`."
"description": "The name of the action currently running, or the [`id`](https://docs.github.com/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepsid) of a step. GitHub Actions removes special characters, and uses the name `__run` when the current step runs a script without an `id`. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name `__run`, and the second script will be named `__run_2`. Similarly, the second invocation of `actions/checkout` will be `actionscheckout2`."
},
"action_path": {
"description": "The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action, for example by changing directories to the path: `cd ${{ github.action_path }}`."
"description": "The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action."
},
"action_ref": {
"description": "For a step executing an action, this is the ref of the action being executed. For example, `v2`."
@@ -71,24 +71,17 @@
"actor": {
"description": "The username of the user that triggered the initial workflow run. If the workflow run is a re-run, this value may differ from `github.triggering_actor`. Any workflow re-runs will use the privileges of `github.actor`, even if the actor initiating the re-run (`github.triggering_actor`) has different privileges."
},
"actor_id": {
"description": "The account ID of the person or app that triggered the initial workflow run. For example, `1234567`. Note that this is different from the actor username.",
"versions": {
"ghes": ">=3.9",
"ghae": ">=3.9"
}
},
"api_url": {
"description": "The URL of the GitHub REST API."
"description": "The URL of the GitHub Actions REST API."
},
"base_ref": {
"description": "The `base_ref` or target branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either `pull_request` or `pull_request_target`."
},
"env": {
"description": "Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#setting-an-environment-variable).\""
"description": "Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see [Workflow commands](https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#setting-an-environment-variable)."
},
"event": {
"description": "The full event webhook payload. You can access individual properties of the event using this context. This object is identical to the webhook payload of the event that triggered the workflow run, and is different for each event. The webhooks for each GitHub Actions event is linked in \"[Events that trigger workflows](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows).\" For example, for a workflow run triggered by the [`push` event](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows#push), this object contains the contents of the [push webhook payload](https://docs.github.com/webhooks-and-events/webhooks/webhook-events-and-payloads#push)."
"description": "The full event webhook payload. You can access individual properties of the event using this context. This object is identical to the webhook payload of the event that triggered the workflow run, and is different for each event. The webhooks for each GitHub Actions event is linked in [Event that trigger workflows](/articles/events-that-trigger-workflows/). For example, for a workflow run triggered by the [`push` event](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows#push), this object contains the contents of the [push webhook payload](https://docs.github.com/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#push)."
},
"event_name": {
"description": "The name of the event that triggered the workflow run."
@@ -97,58 +90,53 @@
"description": "The path to the file on the runner that contains the full event webhook payload."
},
"graphql_url": {
"description": "The URL of the GitHub GraphQL API."
"description": "The URL of the GitHub Actions GraphQL API."
},
"head_ref": {
"description": "The `head_ref` or source branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either `pull_request` or `pull_request_target`."
},
"job": {
"description": "The [`job_id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_id) of the current job.\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
"description": "The [`job_id`](/actions/reference/workflow-syntax-for-github-actions#jobsjob_id) of the current job. <br /> Note: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
},
"job_workflow_sha": {
"description": "For jobs using a reusable workflow, the commit SHA for the reusable workflow file.",
"ref": {
"description": "The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by `push`, this is the branch or tag ref that was pushed. For workflows triggered by `pull_request`, this is the pull request merge branch. For workflows triggered by `release`, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is `refs/heads/<branch_name>`, for pull requests it is `refs/pull/<pr_number>/merge`, and for tags it is `refs/tags/<tag_name>`. For example, `refs/heads/feature-branch-1`.",
"versions": {
"ghes": ">=3.9",
"ghae": ">=3.9"
"ghes": "3.3",
"ghae": "3.3"
}
},
"ref_name": {
"description": "The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, `feature-branch-1`.",
"versions": {
"ghes": "3.3",
"ghae": "3.3"
}
},
"ref_protected": {
"description": "`true` if branch protections are configured for the ref that triggered the workflow run.",
"versions": {
"ghes": "3.3",
"ghae": "3.3"
}
},
"ref_type": {
"description": "The type of ref that triggered the workflow run. Valid values are `branch` or `tag`.",
"versions": {
"ghes": "3.3",
"ghae": "3.3"
}
},
"path": {
"description": "Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path).\""
},
"ref": {
"description": "The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by `push`, this is the branch or tag ref that was pushed. For workflows triggered by `pull_request`, this is the pull request merge branch. For workflows triggered by `release`, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is `refs/heads/<branch_name>`, for pull requests it is `refs/pull/<pr_number>/merge`, and for tags it is `refs/tags/<tag_name>`. For example, `refs/heads/feature-branch-1`."
},
"ref_name": {
"description": "The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, `feature-branch-1`."
},
"ref_protected": {
"description": "`true` if branch protections are configured for the ref that triggered the workflow run."
},
"ref_type": {
"description": "The type of ref that triggered the workflow run. Valid values are `branch` or `tag`."
"description": "Path on the runner to the file that sets system `PATH` variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see [Workflow commands](https://docs.github.com/actions/learn-github-actions/workflow-commands-for-github-actions#adding-a-system-path)."
},
"repository": {
"description": "The owner and repository name. For example, `octocat/Hello-World`."
},
"repository_id": {
"description": "The ID of the repository. For example, `123456789`. Note that this is different from the repository name.",
"versions": {
"ghes": ">=3.9",
"ghae": ">=3.9"
}
"description": "The owner and repository name. For example, `Codertocat/Hello-World`."
},
"repository_owner": {
"description": "The repository owner's username. For example, `octocat`."
},
"repository_owner_id": {
"description": "The repository owner's account ID. For example, `1234567`. Note that this is different from the owner's name.",
"versions": {
"ghes": ">=3.9",
"ghae": ">=3.9"
}
"description": "The repository owner's name. For example, `Codertocat`."
},
"repositoryUrl": {
"description": "The Git URL to the repository. For example, `git://github.com/octocat/hello-world.git`."
"description": "The Git URL to the repository. For example, `git://github.com/codertocat/hello-world.git`."
},
"retention_days": {
"description": "The number of days that workflow run logs and artifacts are kept."
@@ -160,19 +148,27 @@
"description": "A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run."
},
"run_attempt": {
"description": "A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run."
"description": "A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run.",
"versions": {
"ghes": "3.5",
"ghae": "3.4"
}
},
"secret_source": {
"description": "The source of a secret used in a workflow. Possible values are `None`, `Actions`, `Dependabot`, or `Codespaces`."
"description": "The source of a secret used in a workflow. Possible values are `None`, `Actions`, `Dependabot`, or `Codespaces`.",
"versions": {
"ghes": "3.3",
"ghae": "3.3"
}
},
"server_url": {
"description": "The URL of the GitHub server. For example: `https://github.com`."
},
"sha": {
"description": "The commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see \"[Events that trigger workflows](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows).\" For example, `ffac537e6cbbf934b08745a378932722df287a53`."
"description": "The commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see [Events that trigger workflows.](https://docs.github.com/actions/using-workflows/events-that-trigger-workflows) For example, `ffac537e6cbbf934b08745a378932722df287a53`."
},
"token": {
"description": "A token to authenticate on behalf of the GitHub App installed on your repository. This is functionally equivalent to the `GITHUB_TOKEN` secret. For more information, see \"[Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\"\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
"description": "A token to authenticate on behalf of the GitHub App installed on your repository. This is functionally equivalent to the `GITHUB_TOKEN` secret. For more information, see [Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
},
"triggering_actor": {
"description": "The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from `github.actor`. Any workflow re-runs will use the privileges of `github.actor`, even if the actor initiating the re-run (`github.triggering_actor`) has different privileges."
@@ -180,56 +176,13 @@
"workflow": {
"description": "The name of the workflow. If the workflow file doesn't specify a `name`, the value of this property is the full path of the workflow file in the repository."
},
"workflow_ref": {
"description": "The ref path to the workflow. For example, `octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch`.",
"versions": {
"ghes": ">=3.9",
"ghae": ">=3.9"
}
},
"workflow_sha": {
"description": "The commit SHA for the workflow file.",
"versions": {
"ghes": ">=3.9",
"ghae": ">=3.9"
}
},
"workspace": {
"description": "The default working directory on the runner for steps, and the default location of your repository when using the [`checkout`](https://github.com/actions/checkout) action."
}
},
"job": {
"container": {
"description": "Information about the job's container. For more information about containers, see \"[Running jobs in a container](https://docs.github.com/actions/using-jobs/running-jobs-in-a-container).\""
},
"container.id": {
"description": "The ID of the container."
},
"container.network": {
"description": "The ID of the container network. The runner creates the network used by all containers in a job."
},
"services": {
"description": "The service containers created for a job. For more information about service containers, see \"[Using service containers](https://docs.github.com/actions/using-containerized-services/about-service-containers).\""
},
"services.<service_id>.id": {
"description": "The ID of the service container."
},
"services.<service_id>.network": {
"description": "The ID of the service container network. The runner creates the network used by all containers in a job."
},
"services.<service_id>.ports": {
"description": "The exposed ports of the service container."
},
"status": {
"description": "The current status of the job. Possible values are `success`, `failure`, or `cancelled`."
},
"check_run_id": {
"description": "The unique identifier of the check run for this job."
}
},
"secrets": {
"GITHUB_TOKEN": {
"description": "Automatically created token for each workflow run. For more information, see \"[Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\""
"description": "`GITHUB_TOKEN` is a secret that is automatically created for every workflow run, and is always included in the secrets context. For more information, see [Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication)."
}
},
"jobs": {
@@ -242,13 +195,13 @@
},
"steps": {
"outputs": {
"description": "The set of outputs defined for the step. For more information, see \"[Metadata syntax for GitHub Actions](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-docker-container-and-javascript-actions).\""
"description": "The set of outputs defined for the step."
},
"conclusion": {
"description": "The result of a completed step after [`continue-on-error`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscontinue-on-error) is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
"description": "The result of a completed step after `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
},
"outcome": {
"description": "The result of a completed step before [`continue-on-error`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscontinue-on-error) is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
"description": "The result of a completed step before `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
}
},
"runner": {
@@ -265,30 +218,24 @@
"description": "The path to a temporary directory on the runner. This directory is emptied at the beginning and end of each job. Note that files will not be removed if the runner's user account does not have permission to delete them."
},
"tool_cache": {
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software).\""
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software)\"."
},
"debug": {
"description": "This is set only if [`ACTIONS_STEP_DEBUG`](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `\"1\"`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
},
"environment": {
"description": "The environment of the runner executing the job. Possible values are `github-hosted` for GitHub-hosted runners, or `self-hosted` for self-hosted runners."
},
"workspace": {
"description": "The runner-specific working directory path for the job."
"description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of 1. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
}
},
"strategy": {
"fail-fast": {
"description": "When `true`, all in-progress jobs are canceled if any job in a matrix fails. For more information, see \"[Workflow syntax for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast).\""
"description": "The `fail-fast` setting for the job. Possible values are `true` or `false`. For more information, see [Workflow syntax for GitHub Actions: `jobs.<job_id>.strategy.fail-fast`](https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast)."
},
"max-parallel": {
"description": "The `max-parallel` setting for the job. For more information, see [Workflow syntax for GitHub Actions: `jobs.<job_id>.strategy.max-parallel`](https://docs.github.com/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategymax-parallel)."
},
"job-index": {
"description": "The index of the current job in the matrix. **Note:** This number is a zero-based number. The first job's index in the matrix is `0`."
},
"job-total": {
"description": "The total number of jobs in the matrix. **Note:** This number **is not** a zero-based number. For example, for a matrix with four jobs, the value of `job-total` is `4`."
},
"max-parallel": {
"description": "The maximum number of jobs that can run simultaneously when using a matrix job strategy. For more information, see \"[Workflow syntax for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymax-parallel).\""
}
}
}
@@ -1,4 +1,4 @@
import descriptions from "./descriptions.min.json";
import descriptions from "./descriptions.json" assert {type: "json"};
export const RootContext = "root";
const FunctionContext = "functions";
+1 -1
View File
@@ -1,7 +1,7 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {isScalar, isString} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {WorkflowContext} from "../context/workflow-context";
export function getEnvContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();
@@ -1,102 +0,0 @@
import {DescriptionDictionary} from "@actions/expressions";
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads.js";
describe("eventPayloads", () => {
describe("getSupportedEventTypes", () => {
it("returns action types for push event", () => {
const types = getSupportedEventTypes("push");
expect(types).toContain("default");
});
it("returns action types for issues event", () => {
const types = getSupportedEventTypes("issues");
expect(types.length).toBeGreaterThan(1);
expect(types).toContain("opened");
expect(types).toContain("closed");
});
});
describe("getEventPayload", () => {
it("returns payload for push event", () => {
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// Verify common fields exist
expect(payload?.get("ref")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
expect(payload?.get("sender")).toBeDefined();
});
it("returns payload for issues event", () => {
const payload = getEventPayload("issues", "opened");
expect(payload).toBeDefined();
expect(payload?.get("action")).toBeDefined();
expect(payload?.get("issue")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
});
it("preserves descriptions for hover documentation", () => {
// This test ensures bodyParameters[].description is not stripped
// during JSON optimization. The description field is used for hover
// documentation in the workflow editor.
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// Get the description for a well-known field
// repository should have a description like "A repository on GitHub"
const repoDescription = payload?.getDescription("repository");
expect(repoDescription).toBeDefined();
expect(repoDescription?.length).toBeGreaterThan(0);
// sender should have a description
const senderDescription = payload?.getDescription("sender");
expect(senderDescription).toBeDefined();
expect(senderDescription?.length).toBeGreaterThan(0);
});
it("preserves childParamsGroups for nested property access", () => {
// This test ensures bodyParameters[].childParamsGroups is not stripped
// during JSON optimization. childParamsGroups defines nested properties
// used for autocompletion like github.event.repository.owner.login
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// repository has nested properties like owner, license, etc.
const repository = payload?.get("repository") as DescriptionDictionary | undefined;
expect(repository).toBeDefined();
// repository.owner should exist (nested via childParamsGroups)
const owner = repository?.get("owner") as DescriptionDictionary | undefined;
expect(owner).toBeDefined();
// repository.owner.login should exist (deeply nested)
const login = owner?.get("login");
expect(login).toBeDefined();
});
it("preserves name fields for property identification", () => {
// This test ensures bodyParameters[].name is not stripped
// during JSON optimization. The name field identifies each property.
const payload = getEventPayload("issues", "opened");
expect(payload).toBeDefined();
// Verify well-known property names exist
expect(payload?.get("action")).toBeDefined();
expect(payload?.get("issue")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
expect(payload?.get("sender")).toBeDefined();
// Verify nested property names work
const issue = payload?.get("issue") as DescriptionDictionary | undefined;
expect(issue?.get("title")).toBeDefined();
expect(issue?.get("number")).toBeDefined();
expect(issue?.get("user")).toBeDefined();
});
it("returns undefined for unknown event", () => {
const payload = getEventPayload("not_a_real_event", "default");
expect(payload).toBeUndefined();
});
});
});
@@ -1,10 +1,10 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import webhookObjects from "./objects.min.json";
import webhooks from "./webhooks.min.json";
import webhookObjects from "./objects.json";
import webhooks from "./webhooks.json";
import schedule from "./schedule.min.json";
import workflow_call from "./workflow_call.min.json";
import schedule from "./schedule.json" assert {type: "json"};
import workflow_call from "./workflow_call.json" assert {type: "json"};
const customEventPayloads: {[name: string]: unknown} = {
schedule,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js";
import {Mode} from "./default.js";
import {getGithubContext} from "./github.js";
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions/.";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
import {Mode} from "./default";
import {getGithubContext} from "./github";
describe("github context", () => {
it("single event", async () => {
@@ -1,17 +1,14 @@
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {ExpressionData} from "@actions/expressions/data/expressiondata";
import {TypesFilterConfig} from "@actions/workflow-parser/model/workflow-template";
import {WorkflowContext} from "../context/workflow-context.js";
import {Mode} from "./default.js";
import {getDescription} from "./descriptions.js";
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads.js";
import {getInputsContext} from "./inputs.js";
import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default";
import {getDescription} from "./descriptions";
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads";
import {getInputsContext} from "./inputs";
/**
* Returns the github context with properties like actor, ref, sha, event, etc.
*/
export function getGithubContext(workflowContext: WorkflowContext | undefined, mode: Mode): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-cwontext
const keys = [
"action",
"action_path",
@@ -19,7 +16,6 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
"action_repository",
"action_status",
"actor",
"actor_id",
"api_url",
"base_ref",
"env",
@@ -29,16 +25,13 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
"graphql_url",
"head_ref",
"job",
"job_workflow_sha",
"path",
"ref",
"ref_name",
"ref_protected",
"ref_type",
"path",
"repository",
"repository_id",
"repository_owner",
"repository_owner_id",
"repositoryUrl",
"retention_days",
"run_id",
@@ -50,8 +43,6 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
"token",
"triggering_actor",
"workflow",
"workflow_ref",
"workflow_sha",
"workspace"
];
@@ -76,10 +67,7 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
);
}
/**
* Builds the github.event context based on workflow trigger configuration.
*/
function getEventContext(workflowContext: WorkflowContext | undefined, mode: Mode): ExpressionData {
function getEventContext(workflowContext: WorkflowContext, mode: Mode): ExpressionData {
const d = new DescriptionDictionary();
const eventsConfig = workflowContext?.template?.events;
@@ -1,6 +1,6 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {InputConfig} from "@actions/workflow-parser/model/workflow-template";
import {WorkflowContext} from "../context/workflow-context.js";
import {WorkflowContext} from "../context/workflow-context";
export function getInputsContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();
@@ -1,176 +0,0 @@
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {getJobContext} from "./job.js";
function stringToToken(value: string): StringToken {
return new StringToken(undefined, undefined, value, undefined);
}
describe("job context", () => {
it("returns empty context when no job", () => {
const workflowContext = {} as WorkflowContext;
const context = getJobContext(workflowContext);
// When there's no job, context is empty
expect(context.pairs().length).toBe(0);
});
it("returns status and check_run_id when job has no container or services", () => {
const workflowContext = {job: {}} as WorkflowContext;
const context = getJobContext(workflowContext);
expect(context.get("status")).toBeDefined();
expect(context.get("check_run_id")).toBeDefined();
expect(context.get("container")).toBeUndefined();
expect(context.get("services")).toBeUndefined();
});
describe("container context", () => {
it("includes container with id and network when container is defined", () => {
const containerToken = new MappingToken(undefined, undefined, undefined);
containerToken.add(stringToToken("image"), stringToToken("node:18"));
const workflowContext = {
job: {container: containerToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const container = context.get("container");
expect(container).toBeDefined();
if (!container) return;
expect(isDescriptionDictionary(container)).toBe(true);
const containerDict = container as DescriptionDictionary;
expect(containerDict.get("id")).toBeDefined();
expect(containerDict.get("network")).toBeDefined();
expect(containerDict.get("ports")).toBeUndefined(); // job container has no ports
});
it("container has descriptions", () => {
const containerToken = new MappingToken(undefined, undefined, undefined);
containerToken.add(stringToToken("image"), stringToToken("node:18"));
const workflowContext = {
job: {container: containerToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const containerDescription = context.getDescription("container");
expect(containerDescription).toBeDefined();
const containerDict = context.get("container") as DescriptionDictionary;
expect(containerDict.getDescription("id")).toBeDefined();
expect(containerDict.getDescription("network")).toBeDefined();
});
});
describe("services context", () => {
it("includes services with id, network, and ports", () => {
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const services = context.get("services");
expect(services).toBeDefined();
if (!services) return;
expect(isDescriptionDictionary(services)).toBe(true);
const servicesDict = services as DescriptionDictionary;
const redis = servicesDict.get("redis");
expect(redis).toBeDefined();
if (!redis) return;
expect(isDescriptionDictionary(redis)).toBe(true);
const redisDict = redis as DescriptionDictionary;
expect(redisDict.get("id")).toBeDefined();
expect(redisDict.get("network")).toBeDefined();
expect(redisDict.get("ports")).toBeDefined(); // services have ports
});
it("parses service ports in host:container format", () => {
const portsSequence = new SequenceToken(undefined, undefined, undefined);
portsSequence.add(stringToToken("6379:6379"));
portsSequence.add(stringToToken("8080:80"));
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
redisToken.add(stringToToken("ports"), portsSequence);
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const services = context.get("services") as DescriptionDictionary;
const redis = services.get("redis") as DescriptionDictionary;
const ports = redis.get("ports") as DescriptionDictionary;
// Container ports should be the keys (second part of host:container)
expect(ports.get("6379")).toBeDefined();
expect(ports.get("80")).toBeDefined();
});
it("parses service ports in single port format", () => {
const portsSequence = new SequenceToken(undefined, undefined, undefined);
portsSequence.add(stringToToken("6379"));
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
redisToken.add(stringToToken("ports"), portsSequence);
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const services = context.get("services") as DescriptionDictionary;
const redis = services.get("redis") as DescriptionDictionary;
const ports = redis.get("ports") as DescriptionDictionary;
// Single port format uses the port as the key
expect(ports.get("6379")).toBeDefined();
});
it("services have descriptions", () => {
const redisToken = new MappingToken(undefined, undefined, undefined);
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
const servicesToken = new MappingToken(undefined, undefined, undefined);
servicesToken.add(stringToToken("redis"), redisToken);
const workflowContext = {
job: {services: servicesToken}
} as unknown as WorkflowContext;
const context = getJobContext(workflowContext);
const servicesDescription = context.getDescription("services");
expect(servicesDescription).toBeDefined();
const services = context.get("services") as DescriptionDictionary;
const redis = services.get("redis") as DescriptionDictionary;
expect(redis.getDescription("id")).toBeDefined();
expect(redis.getDescription("network")).toBeDefined();
expect(redis.getDescription("ports")).toBeDefined();
});
});
});
+25 -38
View File
@@ -1,12 +1,8 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {isMapping, isSequence} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {getDescription} from "./descriptions.js";
import {WorkflowContext} from "../context/workflow-context";
/**
* Returns the job context with container, services, status, and check_run_id.
*/
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
const jobContext = new DescriptionDictionary();
@@ -19,7 +15,7 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
const jobContainer = job.container;
if (jobContainer && isMapping(jobContainer)) {
const containerContext = createContainerContext(jobContainer, false);
jobContext.add("container", containerContext, getDescription("job", "container"));
jobContext.add("container", containerContext);
}
// Services
@@ -33,48 +29,39 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
const serviceContext = createContainerContext(service.value, true);
servicesContext.add(service.key.toString(), serviceContext);
}
jobContext.add("services", servicesContext, getDescription("job", "services"));
jobContext.add("services", servicesContext);
}
// Status
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
// Check run ID
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
jobContext.add("status", new data.Null());
return jobContext;
}
function createContainerContext(container: MappingToken, isServices: boolean): DescriptionDictionary {
const containerContext = new DescriptionDictionary();
// id and network are always available
containerContext.add(
"id",
new data.StringData(""),
getDescription("job", isServices ? "services.<service_id>.id" : "container.id")
);
containerContext.add(
"network",
new data.StringData(""),
getDescription("job", isServices ? "services.<service_id>.network" : "container.network")
);
// ports are only available for service containers (not job container)
if (isServices) {
const ports = new DescriptionDictionary();
for (const {key, value} of container) {
if (key.toString() === "ports" && isSequence(value)) {
for (const item of value) {
const portParts = item.toString().split(":");
// The key is the container port (second part if host:container format)
const containerPort = portParts.length === 2 ? portParts[1] : portParts[0];
ports.add(containerPort, new data.StringData(""));
function createContainerContext(container: MappingToken, isServices: boolean): data.Dictionary {
const containerContext = new data.Dictionary();
for (const {key, value} of container) {
if (isSequence(value)) {
// service ports are the only thing that is part of the job context
if (key.toString() !== "ports") {
continue;
}
const ports = new data.Dictionary();
for (const item of value) {
// We can determine the context mapping fully only if the port is defined
// as a mapping (i.e. <port1>:<port2>), single ports are assigned randomly
const portParts = item.toString().split(":");
if (isServices && portParts.length === 2) {
ports.add(portParts[1], new data.StringData(portParts[0]));
} else {
// If the port isn't a mapping, just use null
ports.add(portParts[0], new data.Null());
}
}
containerContext.add(key.toString(), ports);
}
containerContext.add("ports", ports, getDescription("job", "services.<service_id>.ports"));
}
containerContext.add("id", new data.Null());
containerContext.add("network", new data.Null());
return containerContext;
}
@@ -1,8 +1,8 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {StringData} from "@actions/expressions/data/string";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {getDescription} from "./descriptions.js";
import {WorkflowContext} from "../context/workflow-context";
import {getDescription} from "./descriptions";
export function getJobsContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#jobs-context
@@ -6,9 +6,9 @@ import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-to
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {Mode} from "./default.js";
import {getMatrixContext} from "./matrix.js";
import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default";
import {getMatrixContext} from "./matrix";
type MatrixMap = {
[key: string]: Array<string> | Array<{[key: string]: string}>;
@@ -64,7 +64,7 @@ describe("matrix context", () => {
expect(workflowContext.job).toBeUndefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null());
expect(context).toEqual(new DescriptionDictionary());
});
it("strategy not defined", () => {
@@ -73,7 +73,7 @@ describe("matrix context", () => {
expect(workflowContext.job!.strategy).toBeUndefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null());
expect(context).toEqual(new DescriptionDictionary());
});
it("strategy is not a mapping token", () => {
@@ -81,7 +81,7 @@ describe("matrix context", () => {
expect(workflowContext.job!.strategy).toBeDefined();
const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null());
expect(context).toEqual(new DescriptionDictionary());
});
it("matrix is not defined", () => {
@@ -3,15 +3,14 @@ import {isBasicExpression, isMapping, isSequence, isString} from "@actions/workf
import {KeyValuePair} from "@actions/workflow-parser/templates/tokens/key-value-pair";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {ContextValue, Mode} from "./default.js";
import {WorkflowContext} from "../context/workflow-context";
import {ContextValue, Mode} from "./default";
export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode): ContextValue {
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
if (!strategy || !isMapping(strategy)) {
// No strategy defined - matrix is null at runtime (not empty object)
return new data.Null();
return new DescriptionDictionary();
}
const matrix = strategy.find("matrix");
@@ -1,8 +1,8 @@
import {DescriptionDictionary} from "@actions/expressions";
import {StringData} from "@actions/expressions/data/string";
import {WorkflowContext} from "../context/workflow-context.js";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js";
import {getNeedsContext} from "./needs.js";
import {WorkflowContext} from "../context/workflow-context";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
import {getNeedsContext} from "./needs";
describe("needs context", () => {
describe("invalid workflow context", () => {
@@ -111,7 +111,7 @@ jobs:
on: push
jobs:
a:
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
uses: ./reusable-workflow-with-outputs.yaml
b:
needs: [a]
@@ -3,7 +3,7 @@ import {isMapping, isScalar, isString} from "@actions/workflow-parser";
import {isJob} from "@actions/workflow-parser/model/type-guards";
import {WorkflowJob} from "@actions/workflow-parser/model/workflow-template";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context.js";
import {WorkflowContext} from "../context/workflow-context";
export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();
@@ -1,8 +1,8 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {StringData} from "@actions/expressions/data/string";
import {WorkflowContext} from "../context/workflow-context.js";
import {Mode} from "./default.js";
import {getDescription} from "./descriptions.js";
import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default";
import {getDescription} from "./descriptions";
export function getSecretsContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
const d = new DescriptionDictionary({
@@ -1,78 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context.js";
import {getStepsContext} from "./steps.js";
function createWorkflowContext(stepIds: string[], currentStepId?: string): WorkflowContext {
return {
job: {
steps: stepIds.map(id => ({id}))
},
step: currentStepId ? {id: currentStepId} : undefined
} as WorkflowContext;
}
describe("steps context", () => {
it("returns empty dictionary when no job", () => {
const workflowContext = {} as WorkflowContext;
const context = getStepsContext(workflowContext);
expect(context.pairs().length).toBe(0);
});
it("returns empty dictionary when no steps", () => {
const workflowContext = {job: {}} as WorkflowContext;
const context = getStepsContext(workflowContext);
expect(context.pairs().length).toBe(0);
});
it("includes steps with user-defined ids", () => {
const workflowContext = createWorkflowContext(["step-a", "step-b"]);
const context = getStepsContext(workflowContext);
expect(context.get("step-a")).toBeDefined();
expect(context.get("step-b")).toBeDefined();
});
it("excludes generated step ids (starting with __)", () => {
const workflowContext = createWorkflowContext(["step-a", "__generated"]);
const context = getStepsContext(workflowContext);
expect(context.get("step-a")).toBeDefined();
expect(context.get("__generated")).toBeUndefined();
});
it("excludes current step and later steps", () => {
const workflowContext = createWorkflowContext(["step-a", "step-b", "step-c"], "step-b");
const context = getStepsContext(workflowContext);
expect(context.get("step-a")).toBeDefined();
expect(context.get("step-b")).toBeUndefined();
expect(context.get("step-c")).toBeUndefined();
});
describe("step outputs", () => {
it("outputs is a dictionary, not null", () => {
const workflowContext = createWorkflowContext(["step-a"]);
const context = getStepsContext(workflowContext);
const stepContext = context.get("step-a");
expect(stepContext).toBeDefined();
expect(isDescriptionDictionary(stepContext!)).toBe(true);
const outputs = (stepContext as DescriptionDictionary).get("outputs");
expect(outputs).toBeDefined();
expect(isDescriptionDictionary(outputs!)).toBe(true);
});
it("outputs is marked incomplete to allow dynamic outputs", () => {
const workflowContext = createWorkflowContext(["step-a"]);
const context = getStepsContext(workflowContext);
const stepContext = context.get("step-a") as DescriptionDictionary;
const outputs = stepContext.get("outputs") as DescriptionDictionary;
// Outputs should be incomplete since we can't know what outputs a step will produce
expect(outputs.complete).toBe(false);
});
});
});
@@ -1,7 +1,7 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {WorkflowContext} from "../context/workflow-context.js";
import {getDescription} from "./descriptions.js";
import {WorkflowContext} from "../context/workflow-context";
import {getDescription} from "./descriptions";
export function getStepsContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary();
@@ -31,10 +31,7 @@ function stepContext(): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context
const d = new DescriptionDictionary();
// Step outputs are dynamic - actions can generate outputs based on their inputs
const outputs = new DescriptionDictionary();
outputs.complete = false;
d.add("outputs", outputs, getDescription("steps", "outputs"));
d.add("outputs", new data.Null(), getDescription("steps", "outputs"));
// Can be "success", "failure", "cancelled", or "skipped"
d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));
@@ -0,0 +1,39 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {isMapping, isScalar, isString} from "@actions/workflow-parser";
import {WorkflowContext} from "../context/workflow-context";
import {scalarToData} from "../utils/scalar-to-data";
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
if (!strategy || !isMapping(strategy)) {
return new DescriptionDictionary(
...keys.map(key => {
return {key, value: new data.Null()};
})
);
}
const strategyContext = new DescriptionDictionary();
for (const pair of strategy) {
if (!isString(pair.key)) {
continue;
}
if (!keys.includes(pair.key.value)) {
continue;
}
const value = isScalar(pair.value) ? scalarToData(pair.value) : new data.Null();
strategyContext.add(pair.key.value, value);
}
for (const key of keys) {
if (!strategyContext.get(key)) {
strategyContext.add(key, new data.Null());
}
}
return strategyContext;
}

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