Compare commits

..

6 Commits

Author SHA1 Message Date
github-actions[bot] 71ff7b49c3 Release extension version 0.3.34 (#288)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-05 09:02:23 -06:00
eric sciple 1a42526360 Fix false positive for literal text in if conditions (#285)
* Fix false positive for literal text in `if` conditions

Use token.value (parsed string without YAML quotes) instead of token.source
(raw YAML text) for expression parsing in single-line strings. This fixes a
false positive where `if: "${{ expr }}"` incorrectly triggered the
"literal text in condition" error because the outer quotes were treated as
literal text.

Follow-up to PR #216
Related issue: https://github.com/github/vscode-github-actions/issues/542

* Move issue reference to comment
2026-01-05 08:33:10 -06:00
eric sciple 1cfe9f9f86 languageserver: add .js extensions to imports (ESM prep) (#259) 2026-01-04 15:00:01 -06:00
github-actions[bot] 6641228870 Release extension version 0.3.33 (#284)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-04 14:05:43 -06:00
eric sciple c1ad4d14df Use property descriptions for completion items (#283)
* Use property descriptions for completion items

* Add test for type description fallback
2026-01-04 13:08:13 -06:00
eric sciple 6a47895521 Use additionalTextEdits for escape hatch completions (#282)
Escape hatch completions now use a two-part edit strategy for VS Code compatibility:

- Main textEdit: Inserts newline and indented content at cursor position
  (empty range so VS Code won't filter based on key text)

- additionalTextEdits: Replaces 'key: ' with 'key:' to remove trailing space

This prevents VS Code from filtering out escape hatches while still
producing the correct final YAML structure.
2026-01-04 13:07:42 -06:00
40 changed files with 364 additions and 129 deletions
+26 -6
View File
@@ -4,6 +4,27 @@
This document outlines the plan to migrate from TypeScript's deprecated `"moduleResolution": "node"` (node10) to `"moduleResolution": "node16"` or `"nodenext"`. This change is necessary because the published ESM packages have extensionless imports that don't work correctly in modern ESM environments.
## TL;DR - Remaining Work
- [x] expressions - Migrated ✅
- [x] workflow-parser - Migrated ✅
- [x] languageservice - Migrated ✅
- [x] languageserver - Add `.js` extensions to imports ✅
- [ ] languageserver - Update `tsconfig.build.json` to `moduleResolution: "node16"` (blocked by vscode-languageserver)
- [ ] languageserver - Upgrade `vscode-languageserver` to stable v10+ when released
**Blocker:** `vscode-languageserver@8.0.2` lacks ESM exports. Stable v10 with `exports` field needed.
### ⚠️ Important: `skipLibCheck: true` Required
All migrated packages use `skipLibCheck: true` in their `tsconfig.build.json`. This works around a TS2386 "Overload signatures must all be optional or required" error in `@types/node/module.d.ts`.
**Why can't we just fix the error?** The error is in `@types/node`, a third-party package maintained by DefinitelyTyped. We can't modify `node_modules`, and upstream fixes take time.
**Is `skipLibCheck` safe?** Yes. It only skips type checking of `.d.ts` files (declaration files from dependencies). Our own `.ts` source files are still fully type-checked. This is a common and recommended workaround for issues in third-party type definitions.
---
## Issues Fixed
This migration will resolve the following issues:
@@ -199,14 +220,13 @@ src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver
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.
**Status:** Partial - `.js` extensions added, waiting for stable `vscode-languageserver` release with ESM exports to complete migration.
**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.
**Completed:** All relative imports in languageserver source files have been updated to use `.js` extensions. This is compatible with the current `moduleResolution: "node"` and will enable a seamless migration once a stable vscode-languageserver version with ESM exports is available.
**Options to resolve:**
- Wait for 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
- Wait for stable vscode-languageserver v10+ with ESM exports
- Use pre-release `vscode-languageserver@10.0.0-next.16` (has proper exports but is unstable)
- Fork or patch the dependency
---
@@ -218,7 +238,7 @@ With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rul
| expressions | 1068 | ✅ Migrated |
| workflow-parser | 292 | ✅ Migrated |
| languageservice | 452 | ✅ Migrated |
| languageserver | 6 files | ⏸️ Deferred (vscode-languageserver lacks ESM exports) |
| languageserver | 31 | 🔶 Partial (`.js` extensions added, awaiting stable vscode-languageserver) |
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/expressions",
"version": "0.3.32",
"version": "0.3.34",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.32",
"version": "0.3.34",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -48,8 +48,8 @@
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.32",
"@actions/workflow-parser": "^0.3.32",
"@actions/languageservice": "^0.3.34",
"@actions/workflow-parser": "^0.3.34",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
+12 -12
View File
@@ -20,18 +20,18 @@ import {
TextDocumentSyncKind
} from "vscode-languageserver";
import {TextDocument} from "vscode-languageserver-textdocument";
import {getClient} from "./client";
import {Commands} from "./commands";
import {contextProviders} from "./context-providers";
import {descriptionProvider} from "./description-provider";
import {getFileProvider} from "./file-provider";
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
import {onCompletion} from "./on-completion";
import {ReadFileRequest, Requests} from "./request";
import {getActionsMetadataProvider} from "./utils/action-metadata";
import {TTLCache} from "./utils/cache";
import {timeOperation} from "./utils/timer";
import {valueProviders} from "./value-providers";
import {getClient} from "./client.js";
import {Commands} from "./commands.js";
import {contextProviders} from "./context-providers.js";
import {descriptionProvider} from "./description-provider.js";
import {getFileProvider} from "./file-provider.js";
import {InitializationOptions, RepositoryContext} from "./initializationOptions.js";
import {onCompletion} from "./on-completion.js";
import {ReadFileRequest, Requests} from "./request.js";
import {getActionsMetadataProvider} from "./utils/action-metadata.js";
import {TTLCache} from "./utils/cache.js";
import {timeOperation} from "./utils/timer.js";
import {valueProviders} from "./value-providers.js";
export function initConnection(connection: Connection) {
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
+3 -3
View File
@@ -1,9 +1,9 @@
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";
import {contextProviders} from "./context-providers.js";
import {RepositoryContext} from "./initializationOptions.js";
import {TTLCache} from "./utils/cache.js";
describe("contextProviders", () => {
const mockCache = new TTLCache();
+5 -5
View File
@@ -3,11 +3,11 @@ import {ContextProviderConfig} from "@actions/languageservice";
import {Mode} from "@actions/languageservice/context-providers/default";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {Octokit} from "@octokit/rest";
import {getSecrets} from "./context-providers/secrets";
import {getStepsContext} from "./context-providers/steps";
import {getVariables} from "./context-providers/variables";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
import {getSecrets} from "./context-providers/secrets.js";
import {getStepsContext} from "./context-providers/steps.js";
import {getVariables} from "./context-providers/variables.js";
import {RepositoryContext} from "./initializationOptions.js";
import {TTLCache} from "./utils/cache.js";
export function contextProviders(
client: Octokit | undefined,
@@ -1,7 +1,7 @@
import {ActionOutputs, ActionReference} from "@actions/languageservice/action";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
export async function getActionOutputs(
octokit: Octokit,
@@ -6,10 +6,10 @@ import {warn} from "@actions/languageservice/log";
import {isMapping, isString} from "@actions/workflow-parser";
import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "../utils/cache";
import {errorStatus} from "../utils/error";
import {getRepoPermission} from "../utils/repo-permission";
import {RepositoryContext} from "../initializationOptions.js";
import {TTLCache} from "../utils/cache.js";
import {errorStatus} from "../utils/error.js";
import {getRepoPermission} from "../utils/repo-permission.js";
export async function getSecrets(
workflowContext: WorkflowContext,
@@ -3,9 +3,9 @@ import {getStepsContext as getDefaultStepsContext} from "@actions/languageservic
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache";
import {getStepsContext} from "./steps";
import {createWorkflowContext} from "../test-utils/workflow-context.js";
import {TTLCache} from "../utils/cache.js";
import {getStepsContext} from "./steps.js";
const workflow = `
name: Caching Primes
@@ -3,8 +3,8 @@ import {parseActionReference} from "@actions/languageservice/action";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache";
import {getActionOutputs} from "./action-outputs";
import {TTLCache} from "../utils/cache.js";
import {getActionOutputs} from "./action-outputs.js";
export async function getStepsContext(
octokit: Octokit,
@@ -7,10 +7,10 @@ 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";
import {errorStatus} from "../utils/error";
import {getRepoPermission} from "../utils/repo-permission";
import {RepositoryContext} from "../initializationOptions.js";
import {TTLCache} from "../utils/cache.js";
import {errorStatus} from "../utils/error.js";
import {getRepoPermission} from "../utils/repo-permission.js";
export async function getVariables(
workflowContext: WorkflowContext,
+3 -3
View File
@@ -1,8 +1,8 @@
import {DescriptionProvider} from "@actions/languageservice/hover";
import {Octokit} from "@octokit/rest";
import {getActionDescription} from "./description-providers/action-description";
import {getActionInputDescription} from "./description-providers/action-input";
import {TTLCache} from "./utils/cache";
import {getActionDescription} from "./description-providers/action-description.js";
import {getActionInputDescription} from "./description-providers/action-input.js";
import {TTLCache} from "./utils/cache.js";
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
@@ -1,9 +1,9 @@
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache";
import {getActionDescription} from "./action-description";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
import {createWorkflowContext} from "../test-utils/workflow-context.js";
import {TTLCache} from "../utils/cache.js";
import {getActionDescription} from "./action-description.js";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
const workflow = `
name: Hello World
@@ -2,8 +2,8 @@ import {actionUrl, parseActionReference} from "@actions/languageservice/action";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> {
if (!isActionStep(step)) {
@@ -2,10 +2,10 @@ import {StringToken} from "@actions/workflow-parser/templates/tokens/string-toke
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
import {createWorkflowContext} from "../test-utils/workflow-context";
import {TTLCache} from "../utils/cache";
import {getActionInputDescription} from "./action-input";
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
import {createWorkflowContext} from "../test-utils/workflow-context.js";
import {TTLCache} from "../utils/cache.js";
import {getActionInputDescription} from "./action-input.js";
const workflow = `
name: Hello World
@@ -4,8 +4,8 @@ import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Step} from "@actions/workflow-parser/model/workflow-template";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
export async function getActionInputDescription(
client: Octokit,
+1 -1
View File
@@ -2,7 +2,7 @@ import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "./utils/cache";
import {TTLCache} from "./utils/cache.js";
import * as vscodeURI from "vscode-uri";
export function getFileProvider(
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from "vscode-languageserver/browser";
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
import {initConnection} from "./connection";
import {initConnection} from "./connection.js";
/** Helper function determining whether we are executing with node runtime */
function isNode(): boolean {
+6 -6
View File
@@ -2,12 +2,12 @@ import {complete} from "@actions/languageservice/complete";
import {Octokit} from "@octokit/rest";
import {CompletionItem, Connection, Position} from "vscode-languageserver";
import {TextDocument} from "vscode-languageserver-textdocument";
import {contextProviders} from "./context-providers";
import {getFileProvider} from "./file-provider";
import {RepositoryContext} from "./initializationOptions";
import {Requests} from "./request";
import {TTLCache} from "./utils/cache";
import {valueProviders} from "./value-providers";
import {contextProviders} from "./context-providers.js";
import {getFileProvider} from "./file-provider.js";
import {RepositoryContext} from "./initializationOptions.js";
import {Requests} from "./request.js";
import {TTLCache} from "./utils/cache.js";
import {valueProviders} from "./value-providers.js";
export async function onCompletion(
connection: Connection,
@@ -1,7 +1,7 @@
import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock";
import {fetchActionMetadata} from "./action-metadata";
import {TTLCache} from "./cache";
import {fetchActionMetadata} from "./action-metadata.js";
import {TTLCache} from "./cache.js";
// A simplified version of the action.yml file from actions/checkout
const actionMetadataContent = `
+2 -2
View File
@@ -3,8 +3,8 @@ import {ActionsMetadataProvider} from "@actions/languageservice";
import {error} from "@actions/languageservice/log";
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
import {parse} from "yaml";
import {TTLCache} from "./cache";
import {errorMessage, errorStatus} from "./error";
import {TTLCache} from "./cache.js";
import {errorMessage, errorStatus} from "./error.js";
export function getActionsMetadataProvider(
client: Octokit | undefined,
+4 -4
View File
@@ -1,9 +1,9 @@
import {error} from "@actions/languageservice/log";
import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "../initializationOptions";
import {TTLCache} from "./cache";
import {errorStatus} from "./error";
import {getUsername} from "./username";
import {RepositoryContext} from "../initializationOptions.js";
import {TTLCache} from "./cache.js";
import {errorStatus} from "./error.js";
import {getUsername} from "./username.js";
export type RepoPermission = "admin" | "write" | "read" | "none";
+1 -1
View File
@@ -1,5 +1,5 @@
import {Octokit} from "@octokit/rest";
import {TTLCache} from "./cache";
import {TTLCache} from "./cache.js";
export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> {
return await cache.get(`/username`, undefined, () => fetchUsername(octokit));
+5 -5
View File
@@ -2,11 +2,11 @@ import {ValueProviderConfig} from "@actions/languageservice";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {ValueProviderKind} from "@actions/languageservice/value-providers/config";
import {Octokit} from "@octokit/rest";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
import {getActionInputValues} from "./value-providers/action-inputs";
import {getEnvironments} from "./value-providers/job-environment";
import {getRunnerLabels} from "./value-providers/runs-on";
import {RepositoryContext} from "./initializationOptions.js";
import {TTLCache} from "./utils/cache.js";
import {getActionInputValues} from "./value-providers/action-inputs.js";
import {getEnvironments} from "./value-providers/job-environment.js";
import {getRunnerLabels} from "./value-providers/runs-on.js";
export function valueProviders(
client: Octokit | undefined,
@@ -3,8 +3,8 @@ import {WorkflowContext} from "@actions/languageservice/context/workflow-context
import {Value} from "@actions/languageservice/value-providers/config";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {Octokit} from "@octokit/rest";
import {fetchActionMetadata} from "../utils/action-metadata";
import {TTLCache} from "../utils/cache";
import {fetchActionMetadata} from "../utils/action-metadata.js";
import {TTLCache} from "../utils/cache.js";
export async function getActionInputs(
client: Octokit,
@@ -1,6 +1,6 @@
import {Value} from "@actions/languageservice/value-providers/config";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache";
import {TTLCache} from "../utils/cache.js";
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
@@ -2,8 +2,8 @@ import {log} from "@actions/languageservice/log";
import {Value} from "@actions/languageservice/value-providers/config";
import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default";
import {Octokit} from "@octokit/rest";
import {TTLCache} from "../utils/cache";
import {errorMessage} from "../utils/error";
import {TTLCache} from "../utils/cache.js";
import {errorMessage} from "../utils/error.js";
// Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners.
// It doesn't return labels for organization runners visible to the repository.
+2 -1
View File
@@ -5,6 +5,7 @@
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "./dist"
"outDir": "./dist",
"skipLibCheck": true
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageservice",
"version": "0.3.32",
"version": "0.3.34",
"description": "Language service for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -47,8 +47,8 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.32",
"@actions/workflow-parser": "^0.3.32",
"@actions/expressions": "^0.3.34",
"@actions/workflow-parser": "^0.3.34",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -251,6 +251,38 @@ runs:
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});
+27 -8
View File
@@ -723,16 +723,28 @@ jobs:
expect(switchToList!.sortText).toEqual("zzz_switch_1");
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
// Escape hatches should have textEdit that restructures the YAML
// 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;
expect(listEdit.newText).toEqual("runs-on:\n - ");
expect(fullEdit.newText).toEqual("runs-on:\n ");
// Main textEdit inserts newline and indented content at cursor position
expect(listEdit.newText).toEqual("\n - ");
expect(fullEdit.newText).toEqual("\n ");
// TextEdit range should cover from key start to cursor position
expect(listEdit.range.start).toEqual({line: 3, character: 4});
expect(fullEdit.range.start).toEqual({line: 3, character: 4});
// 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 () => {
@@ -824,9 +836,16 @@ jobs:
const switchToList = result.find(x => x.label === "(switch to list)");
const textEdit = switchToList!.textEdit as TextEdit;
const additionalEdits = switchToList!.additionalTextEdits!;
// Applying this edit to "runs-on: " should produce "runs-on:\n - "
expect(textEdit.newText).toEqual("runs-on:\n - ");
// 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 - "
});
});
+39 -10
View File
@@ -192,6 +192,12 @@ export async function complete(
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,
@@ -202,7 +208,8 @@ export async function complete(
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit
textEdit,
additionalTextEdits
};
return item;
@@ -388,9 +395,19 @@ function getEscapeHatchCompletions(
return [];
}
// Calculate the range from key start to current position
// This covers "key: " so we can replace it with "key:\n - " or "key:\n "
const editRange = {
// 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}
};
@@ -400,9 +417,15 @@ function getEscapeHatchCompletions(
label: "(switch to list)",
sortText: "zzz_switch_1",
textEdit: {
range: editRange,
newText: `${keyName}:\n${indentation}- `
}
range: cursorRange,
newText: `\n${indentation}- `
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
@@ -411,9 +434,15 @@ function getEscapeHatchCompletions(
label: "(switch to mapping)",
sortText: "zzz_switch_2",
textEdit: {
range: editRange,
newText: `${keyName}:\n${indentation}`
}
range: cursorRange,
newText: `\n${indentation}`
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
+15
View File
@@ -53,6 +53,20 @@ ru|ns:
expect(result).not.toBeNull();
expect(result?.contents).toContain("runs");
});
it("shows description for author key", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
au|thor: Me
runs:
using: node20
main: index.js`);
const result = await hover(doc, position);
expect(result).not.toBeNull();
expect(result?.contents).toContain("author");
expect(result?.contents).toContain("Documentation");
});
});
describe("runs properties", () => {
@@ -145,6 +159,7 @@ brand|ing:
expect(result).not.toBeNull();
expect(result?.contents).toContain("brand");
expect(result?.contents).toContain("Documentation");
});
it("shows description for icon key", async () => {
@@ -211,4 +211,104 @@ jobs:
);
});
});
// https://github.com/github/vscode-github-actions/issues/542
describe("YAML-quoted expressions", () => {
it("allows double-quoted expression in job-if", async () => {
// Quotes are needed when the expression contains a colon
const input = `
on: push
jobs:
publish:
if: "\${{ startsWith(github.event.head_commit.message, 'chore: release') }}"
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
it("allows single-quoted expression in job-if", async () => {
const input = `
on: push
jobs:
publish:
if: '\${{ startsWith(github.event.head_commit.message, "chore: release") }}'
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
it("allows double-quoted expression in step-if", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: "\${{ contains(github.event.head_commit.message, 'skip: ci') }}"
run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
it("still errors when there is actual literal text outside expression", async () => {
// Even with quotes, if there's literal text outside ${{ }}, it should error
const input = `
on: push
jobs:
build:
if: "push == \${{ github.event_name }}"
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
it("errors on multiple expressions with literal text between them", async () => {
const input = `
on: push
jobs:
build:
if: "\${{ true }} and \${{ false }}"
runs-on: ubuntu-latest
steps:
- run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
code: "expression-literal-text-in-condition"
})
);
});
});
});
@@ -27,6 +27,12 @@ export interface Value {
range: {start: {line: number; character: number}; end: {line: number; character: number}};
newText: string;
};
/** Additional text edits to apply after the main edit (e.g., cleanup edits) */
additionalTextEdits?: {
range: {start: {line: number; character: number}; end: {line: number; character: number}};
newText: string;
}[];
}
export enum ValueProviderKind {
@@ -107,10 +107,14 @@ function mappingValues(
for (const [key, value] of Object.entries(mappingDefinition.properties)) {
let insertText: string | undefined;
let description: string | undefined;
// Prefer the property's own description (from the schema's property definition),
// fall back to the type definition's description if the property doesn't have one
let description: string | undefined = value.description;
if (value.type) {
const typeDef = definitions[value.type];
description = typeDef?.description;
if (!description) {
description = typeDef?.description;
}
if (typeDef) {
switch (typeDef.definitionType) {
+1 -1
View File
@@ -6,5 +6,5 @@
"languageservice",
"languageserver"
],
"version": "0.3.32"
"version": "0.3.34"
}
+9 -9
View File
@@ -136,7 +136,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.32",
"version": "0.3.34",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.32",
"version": "0.3.34",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.32",
"@actions/workflow-parser": "^0.3.32",
"@actions/languageservice": "^0.3.34",
"@actions/workflow-parser": "^0.3.34",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
@@ -940,11 +940,11 @@
},
"languageservice": {
"name": "@actions/languageservice",
"version": "0.3.32",
"version": "0.3.34",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.32",
"@actions/workflow-parser": "^0.3.32",
"@actions/expressions": "^0.3.34",
"@actions/workflow-parser": "^0.3.34",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -13345,10 +13345,10 @@
},
"workflow-parser": {
"name": "@actions/workflow-parser",
"version": "0.3.32",
"version": "0.3.34",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.32",
"@actions/expressions": "^0.3.34",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.32",
"version": "0.3.34",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -48,7 +48,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.32",
"@actions/expressions": "^0.3.34",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
@@ -451,7 +451,13 @@ class TemplateReader {
}
const allowedContext = definitionInfo.allowedContext;
const raw = token.source || token.value;
const isSingleLine = token.range === undefined || token.range.start.line === token.range.end.line;
// For single-line strings, use token.value (without YAML quotes) for expression detection,
// because token.source includes quote characters that would be incorrectly detected as literal text.
// For multi-line block scalars, use token.source directly because it makes position calculation easier
// (no quote characters to handle, and token.source preserves the original line/column structure in YAML).
const raw = isSingleLine ? token.value : token.source ?? token.value;
let startExpression: number = raw.indexOf(OPEN_EXPRESSION);
if (startExpression < 0) {
@@ -496,14 +502,17 @@ class TemplateReader {
);
let tr = token.range!;
if (tr.start.line === tr.end.line) {
// If it's a single line expression, adjust the range to only cover the sub-expression
if (isSingleLine) {
// Single-line: Adjust the range to only cover the sub-expression.
// Calculate offset to account for YAML quote characters.
// For example, `"${{ expr }}"` has source with quotes, value without.
const offset = (token.source ?? raw).indexOf(OPEN_EXPRESSION) - raw.indexOf(OPEN_EXPRESSION);
tr = {
start: {line: tr.start.line, column: tr.start.column + startExpression},
end: {line: tr.end.line, column: tr.start.column + endExpression + 1}
start: {line: tr.start.line, column: tr.start.column + startExpression + offset},
end: {line: tr.end.line, column: tr.start.column + endExpression + 1 + offset}
};
} else {
// Adjust the range to only cover the expression for multi-line strings
// Multi-line: Adjust the range to only cover the expression
const startRaw = raw.substring(0, startExpression);
const adjustedStartLine = startRaw.split("\n").length;
const beginningOfLine = startRaw.lastIndexOf("\n");