Compare commits

..

14 Commits

Author SHA1 Message Date
github-actions[bot] 1baa74a67e Release extension version 0.3.35 (#297)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-13 19:27:39 -06:00
eric sciple fa27dfa563 Add action.yml scaffolding snippets (#296) 2026-01-13 09:14:20 -06:00
eric sciple 2816233a40 Add block scalar newline warning (#295)
In YAML, block scalars (`|` and `>`) silently add a trailing newline by default
("clip" chomping). This can cause subtle bugs when the newline is unintentional.

This PR adds a warning when clip chomping is used in fields where trailing
newlines commonly cause issues:

- Environment variables (workflow, job, step, container, service levels)
- Action inputs (`with:`)
- Reusable workflow inputs and secrets
- Job outputs
- Matrix values (including `include` and `exclude`)
- Concurrency groups

The warning suggests using `|-` (strip) or `|+` (keep) to be explicit.

Intentionally does NOT warn for:
- `run:` scripts (trailing newlines are normal)
- Fields trimmed server-side (`if:`, `name:`, `runs-on:`, etc.)

The feature is gated behind the `blockScalarChompingWarning` feature flag.
2026-01-12 09:36:43 -06:00
eric sciple 54404aa9ff Add format string validation (#292)
Validates format() function calls for:
- Invalid syntax (missing closing brace, empty placeholder, non-numeric placeholder)
- Argument count mismatch (placeholder references arg that doesn't exist)

Port of Go's format_validator.go from actions-workflow-parser.
2026-01-08 09:25:37 -06:00
eric sciple dbf7752734 Show cron description on hover (#291)
Related #286 - When hovering over a cron expression, show the human-readable
description instead of empty content. Users who have inlay hints disabled
can now still see the cron description.
2026-01-07 08:43:22 -06:00
eric sciple 78231482f5 Fix completion and validation issues in action.yml (#290)
Follow-up to https://github.com/actions/languageservices/pull/289

## What this fixes

**Autocomplete was broken inside composite action steps.** When you typed inside a step and triggered autocomplete, nothing showed up. Now you correctly get suggestions like run, uses, shell, etc.

**Duplicate error messages for missing required fields.** When a required field was missing (like main for Node.js actions), users saw two error messages - one generic schema validation error, and one custom error with a clear explanation. Now they only see the custom one.

For example, with using: node24 but no main:
- Before: Two errors shown
  - Schema: "There's not enough info to determine what you meant. Add one of these properties: args, entrypoint, image, main, ..."
  - Custom: "'main' is required for Node.js actions (using: node24)"
- After: Only the custom error is shown
2026-01-07 08:42:59 -06:00
eric sciple 2e46c66878 Context-aware autocomplete and validation for action.yml runs section (#289)
- Set main as required in node-runs-strict schema definition
- Add validation for invalid key combinations based on using value
- Add validation for missing required keys (main for node, steps for composite, image for docker)
- Filter autocomplete suggestions based on using value
- Prioritize 'using' in completions when not set yet

Fixes context-aware autocomplete for action.yml files where different
action types (node, composite, docker) have different valid keys under runs:
2026-01-06 21:09:38 -06:00
Francesco Renzi 39b9b14e3a Add experimentalFeatures to initialization options (#287)
* Add experimentalFeatures to initialization options

Introduce a feature flagging system for opt-in experimental features.
Clients can enable features via initializationOptions.experimentalFeatures
with granular per-feature control or an 'all' flag to enable everything.

First experimental feature: missingInputsQuickfix (for upcoming code actions)
2026-01-06 07:03:51 +00:00
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
63 changed files with 3635 additions and 161 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.35",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+61
View File
@@ -0,0 +1,61 @@
import {FeatureFlags} from "./features.js";
describe("FeatureFlags", () => {
describe("isEnabled", () => {
it("returns false by default when no options provided", () => {
const flags = new FeatureFlags();
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns false by default when empty options provided", () => {
const flags = new FeatureFlags({});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns true when feature is explicitly enabled", () => {
const flags = new FeatureFlags({missingInputsQuickfix: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
it("returns false when feature is explicitly disabled", () => {
const flags = new FeatureFlags({missingInputsQuickfix: false});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("returns true when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
it("explicit feature flag takes precedence over all:true", () => {
const flags = new FeatureFlags({all: true, missingInputsQuickfix: false});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
});
it("explicit feature flag takes precedence over all:false", () => {
const flags = new FeatureFlags({all: false, missingInputsQuickfix: true});
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
});
});
describe("getEnabledFeatures", () => {
it("returns empty array when no features enabled", () => {
const flags = new FeatureFlags();
expect(flags.getEnabledFeatures()).toEqual([]);
});
it("returns enabled features", () => {
const flags = new FeatureFlags({missingInputsQuickfix: true});
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
});
it("returns all features when all is enabled", () => {
const flags = new FeatureFlags({all: true});
expect(flags.getEnabledFeatures()).toEqual([
"missingInputsQuickfix",
"blockScalarChompingWarning",
"actionScaffoldingSnippets"
]);
});
});
});
+84
View File
@@ -0,0 +1,84 @@
/**
* Experimental feature flags.
*
* Individual feature flags take precedence over `all`.
* Example: { all: true, missingInputsQuickfix: false } enables all
* experimental features EXCEPT missingInputsQuickfix.
*
* When a feature graduates to stable, its flag becomes a no-op
* (the feature will be enabled regardless of the configuration value).
*/
export interface ExperimentalFeatures {
/**
* Enable all experimental features.
* Individual feature flags take precedence over this setting.
* @default false
*/
all?: boolean;
/**
* Enable quickfix code action for missing required action inputs.
* @default false
*/
missingInputsQuickfix?: boolean;
/**
* Warn when block scalars (| or >) use implicit clip chomping,
* which adds a trailing newline that may be unintentional.
* @default false
*/
blockScalarChompingWarning?: boolean;
/**
* Enable action scaffolding snippets in action.yml files.
* Offers Node.js, Composite, and Docker action scaffolds.
* @default false
*/
actionScaffoldingSnippets?: boolean;
}
/**
* Keys of ExperimentalFeatures that represent actual features (excludes 'all')
*/
export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
/**
* All known experimental feature keys.
* This list must be kept in sync with the ExperimentalFeatures interface.
*/
const allFeatureKeys: ExperimentalFeatureKey[] = [
"missingInputsQuickfix",
"blockScalarChompingWarning",
"actionScaffoldingSnippets"
];
export class FeatureFlags {
private readonly features: ExperimentalFeatures;
constructor(features?: ExperimentalFeatures) {
this.features = features ?? {};
}
/**
* Check if an experimental feature is enabled.
*
* Resolution order:
* 1. Explicit feature flag (if set)
* 2. `all` flag (if set)
* 3. false (default)
*/
isEnabled(feature: ExperimentalFeatureKey): boolean {
const explicit = this.features[feature];
if (explicit !== undefined) {
return explicit;
}
return this.features.all ?? false;
}
/**
* Returns list of all enabled experimental features.
*/
getEnabledFeatures(): ExperimentalFeatureKey[] {
return allFeatureKeys.filter(key => this.isEnabled(key));
}
}
+1
View File
@@ -4,6 +4,7 @@ export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from ".
export * as data from "./data/index.js";
export {ExpressionError, ExpressionEvaluationError} from "./errors.js";
export {Evaluator} from "./evaluator.js";
export {ExperimentalFeatureKey, ExperimentalFeatures, FeatureFlags} from "./features.js";
export {wellKnownFunctions} from "./funcs.js";
export {Lexer, Result} from "./lexer.js";
export {Parser} from "./parser.js";
+32
View File
@@ -84,6 +84,11 @@ export interface InitializationOptions {
* Desired log level
*/
logLevel?: LogLevel;
/**
* Experimental features that are opt-in
*/
experimentalFeatures?: ExperimentalFeatures;
}
```
@@ -100,6 +105,33 @@ const clientOptions: LanguageClientOptions = {
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
```
### Experimental Features
The language server supports opt-in experimental features via the `experimentalFeatures` initialization option. These features may change or be removed in between releases.
```typescript
initializationOptions: {
experimentalFeatures: {
// Enable all experimental features
all: true,
// Or enable specific features
missingInputsQuickfix: true,
}
}
```
**Available experimental features:**
| Feature | Description |
|---------|-------------|
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
When a feature graduates to stable, its flag becomes a no-op and the feature will be enabled regardless of the configuration value.
### Standalone CLI
After installing globally, you can run the language server directly:
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/languageserver",
"version": "0.3.32",
"version": "0.3.35",
"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.35",
"@actions/workflow-parser": "^0.3.35",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
+23 -13
View File
@@ -20,18 +20,19 @@ 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 {FeatureFlags} from "@actions/expressions";
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);
@@ -41,6 +42,7 @@ export function initConnection(connection: Connection) {
const cache = new TTLCache();
let hasWorkspaceFolderCapability = false;
let featureFlags = new FeatureFlags();
// Register remote console logger with language service
registerLogger(connection.console);
@@ -64,6 +66,8 @@ export function initConnection(connection: Connection) {
setLogLevel(options.logLevel);
}
featureFlags = new FeatureFlags(options.experimentalFeatures);
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Full,
@@ -91,6 +95,11 @@ export function initConnection(connection: Connection) {
});
connection.onInitialized(() => {
const enabledFeatures = featureFlags.getEnabledFeatures();
if (enabledFeatures.length > 0) {
connection.console.info(`Experimental features enabled: ${enabledFeatures.join(", ")}`);
}
if (hasWorkspaceFolderCapability) {
connection.workspace.onDidChangeWorkspaceFolders(() => {
clearCache();
@@ -114,7 +123,8 @@ export function initConnection(connection: Connection) {
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
})
}),
featureFlags
};
const result = await validate(textDocument, config);
+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 {
@@ -1,3 +1,4 @@
import {ExperimentalFeatures} from "@actions/expressions";
import {LogLevel} from "@actions/languageservice/log";
export {LogLevel} from "@actions/languageservice/log";
@@ -28,6 +29,12 @@ export interface InitializationOptions {
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
*/
gitHubApiUrl?: string;
/**
* Experimental features that are opt-in.
* Features listed here may change or be removed without notice.
*/
experimentalFeatures?: ExperimentalFeatures;
}
export interface RepositoryContext {
+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.35",
"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.35",
"@actions/workflow-parser": "^0.3.35",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
+295 -1
View File
@@ -1,11 +1,17 @@
import {FeatureFlags} from "@actions/expressions";
import {TextDocument} from "vscode-languageserver-textdocument";
import {complete} from "./complete";
import {complete, CompletionConfig} from "./complete";
import {clearCache} from "./utils/workflow-cache";
beforeEach(() => {
clearCache();
});
// Config to enable action scaffolding snippets
const scaffoldingConfig: CompletionConfig = {
featureFlags: new FeatureFlags({actionScaffoldingSnippets: true})
};
describe("complete action files", () => {
function createActionDocument(
content: string,
@@ -184,6 +190,107 @@ runs:
expect(labels).toContain("using");
});
it("filters runs keys for node20 actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node20
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show Node.js action keys
expect(labels).toContain("main");
expect(labels).toContain("pre");
expect(labels).toContain("post");
expect(labels).toContain("pre-if");
expect(labels).toContain("post-if");
// Should NOT show composite or docker keys
expect(labels).not.toContain("steps");
expect(labels).not.toContain("image");
expect(labels).not.toContain("entrypoint");
});
it("filters runs keys for composite actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: composite
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show composite action keys
expect(labels).toContain("steps");
// Should NOT show Node.js or docker keys
expect(labels).not.toContain("main");
expect(labels).not.toContain("pre");
expect(labels).not.toContain("post");
expect(labels).not.toContain("image");
});
it("filters runs keys for docker actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: docker
|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show Docker action keys
expect(labels).toContain("image");
expect(labels).toContain("args");
expect(labels).toContain("env");
expect(labels).toContain("entrypoint");
expect(labels).toContain("pre-entrypoint");
expect(labels).toContain("post-entrypoint");
// Should NOT show Node.js or composite keys
expect(labels).not.toContain("main");
expect(labels).not.toContain("steps");
});
it("prioritizes using when not set", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
|`);
const completions = await complete(doc, position);
// Find the using completion
const usingCompletion = completions.find(c => c.label === "using");
expect(usingCompletion).toBeDefined();
// It should have a sortText that makes it sort first
expect(usingCompletion?.sortText).toBe("0_using");
});
it("completes step keys inside composite action steps", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: composite
steps:
- run: echo hello
shell: bash
- |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show step keys, not filtered by runs-level logic
expect(labels).toContain("run");
expect(labels).toContain("uses");
expect(labels).toContain("shell");
expect(labels).toContain("id");
expect(labels).toContain("name");
expect(labels).toContain("if");
expect(labels).toContain("env");
expect(labels).toContain("working-directory");
});
});
describe("branding completions", () => {
@@ -251,6 +358,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});
@@ -260,4 +399,159 @@ runs:
expect(labels).toContain("jobs");
});
});
describe("action scaffolding snippets", () => {
it("offers full scaffolding snippets in empty file", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position, scaffoldingConfig);
const labels = completions.map(c => c.label);
expect(labels).toContain("Node.js Action");
expect(labels).toContain("Composite Action");
expect(labels).toContain("Docker Action");
// Verify they are snippets
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
expect(nodeSnippet?.kind).toBe(15); // CompletionItemKind.Snippet
expect(nodeSnippet?.insertTextFormat).toBe(2); // InsertTextFormat.Snippet
});
it("offers full scaffolding snippets when no name or description exists", async () => {
const [doc, position] = createActionDocument(`author: me
|`);
const completions = await complete(doc, position, scaffoldingConfig);
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
expect(nodeSnippet).toBeDefined();
// Full snippet should include name:
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("name:");
});
it("offers runs-only snippets when name exists", async () => {
const [doc, position] = createActionDocument(`name: My Action
|`);
const completions = await complete(doc, position, scaffoldingConfig);
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
expect(nodeSnippet).toBeDefined();
// Runs-only snippet should start with inputs:, not name:
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/);
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:");
});
it("offers runs-only snippets when description exists", async () => {
const [doc, position] = createActionDocument(`description: Does something
|`);
const completions = await complete(doc, position, scaffoldingConfig);
const compositeSnippet = completions.find(c => c.label === "Composite Action");
expect(compositeSnippet).toBeDefined();
// Runs-only snippet should start with inputs:, not description:
expect((compositeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/);
expect((compositeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:");
});
it("does not offer snippets when runs.using already exists", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
using: composite
|`);
const completions = await complete(doc, position, scaffoldingConfig);
const labels = completions.map(c => c.label);
expect(labels).not.toContain("Node.js Action");
expect(labels).not.toContain("Composite Action");
expect(labels).not.toContain("Docker Action");
});
it("offers snippets inside runs when using is not set", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
|`);
const completions = await complete(doc, position, scaffoldingConfig);
const labels = completions.map(c => c.label);
expect(labels).toContain("Node.js Action");
expect(labels).toContain("Composite Action");
expect(labels).toContain("Docker Action");
});
it("does not offer snippets at root level when runs exists", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
steps: []
|`);
const completions = await complete(doc, position, scaffoldingConfig);
const labels = completions.map(c => c.label);
expect(labels).not.toContain("Node.js Action");
expect(labels).not.toContain("Composite Action");
expect(labels).not.toContain("Docker Action");
});
it("does not offer snippets when nested inside runs steps", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test
runs:
using: composite
steps:
- |`);
const completions = await complete(doc, position, scaffoldingConfig);
const labels = completions.map(c => c.label);
expect(labels).not.toContain("Node.js Action");
expect(labels).not.toContain("Composite Action");
expect(labels).not.toContain("Docker Action");
});
it("Node.js snippet contains expected content", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position, scaffoldingConfig);
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
const text = (nodeSnippet?.textEdit as {newText: string})?.newText;
expect(text).toContain("using: node24");
expect(text).toContain("main:");
expect(text).toContain("inputs:");
expect(text).toContain("outputs:");
});
it("Composite snippet contains expected content", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position, scaffoldingConfig);
const compositeSnippet = completions.find(c => c.label === "Composite Action");
const text = (compositeSnippet?.textEdit as {newText: string})?.newText;
expect(text).toContain("using: composite");
expect(text).toContain("steps:");
expect(text).toContain("shell: bash");
});
it("Docker snippet contains expected content", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position, scaffoldingConfig);
const dockerSnippet = completions.find(c => c.label === "Docker Action");
const text = (dockerSnippet?.textEdit as {newText: string})?.newText;
expect(text).toContain("using: docker");
expect(text).toContain("image:");
expect(text).toContain("entrypoint:");
});
it("does not offer snippets when feature flag is disabled", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
expect(labels).not.toContain("Node.js Action");
expect(labels).not.toContain("Composite Action");
expect(labels).not.toContain("Docker Action");
});
});
});
+468
View File
@@ -0,0 +1,468 @@
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {Position} from "vscode-languageserver-textdocument";
import {CompletionItem, CompletionItemKind, InsertTextFormat, TextEdit} from "vscode-languageserver-types";
import {Value} from "./value-providers/config.js";
/**
* Valid keys for each action type under the `runs:` section.
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
*/
const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]);
const ACTION_DOCKER_KEYS = new Set([
"using",
"image",
"args",
"env",
"entrypoint",
"pre-entrypoint",
"pre-if",
"post-entrypoint",
"post-if"
]);
/**
* Action scaffolding snippets.
*
* Full variants include name, description, inputs, outputs, and runs.
* Runs-only variants include just the runs block.
*/
const ACTION_SNIPPET_NODEJS_FULL = `name: '\${1:Action Name}'
description: '\${2:What this action does}'
inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on JavaScript actions (including @actions/toolkit), see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
using: node24
main: index.js
# Sample index.js (vanilla JS, no build required):
#
# const fs = require('fs');
# const name = process.env.INPUT_NAME || 'World';
# const greeting = \\\`Hello \\\${name}\\\`;
# console.log(greeting);
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
#
# For JavaScript actions with @actions/toolkit, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
`;
const ACTION_SNIPPET_NODEJS_RUNS = `inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on JavaScript actions (including @actions/toolkit), see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
using: node24
main: index.js
# Sample index.js (vanilla JS, no build required):
#
# const fs = require('fs');
# const name = process.env.INPUT_NAME || 'World';
# const greeting = \\\`Hello \\\${name}\\\`;
# console.log(greeting);
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
`;
const ACTION_SNIPPET_NODEJS_USING = `# For more on JavaScript actions (including @actions/toolkit), see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
using: node24
main: index.js
# Sample index.js (vanilla JS, no build required):
#
# console.log('Hello World');
`;
const ACTION_SNIPPET_COMPOSITE_FULL = `name: '\${1:Action Name}'
description: '\${2:What this action does}'
inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
value: \\\${{ steps.greet.outputs.greeting }}
runs:
# For more on composite actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
using: composite
steps:
- id: greet
shell: bash
env:
INPUT_NAME: \\\${{ inputs.name }}
run: |
GREETING="Hello $INPUT_NAME"
echo "$GREETING"
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
`;
const ACTION_SNIPPET_COMPOSITE_RUNS = `inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
value: \\\${{ steps.greet.outputs.greeting }}
runs:
# For more on composite actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
using: composite
steps:
- id: greet
shell: bash
env:
INPUT_NAME: \\\${{ inputs.name }}
run: |
GREETING="Hello $INPUT_NAME"
echo "$GREETING"
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
`;
const ACTION_SNIPPET_COMPOSITE_USING = `# For more on composite actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
using: composite
steps:
- shell: bash
run: echo "Hello World"
`;
const ACTION_SNIPPET_DOCKER_FULL = `name: '\${1:Action Name}'
description: '\${2:What this action does}'
inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on Docker actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
using: docker
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
image: '\${3:docker://alpine:3.20}'
env:
INPUT_NAME: \\\${{ inputs.name }}
entrypoint: '\${4:sh}'
args:
- -c
- |
GREETING="Hello $INPUT_NAME"
echo "$GREETING"
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
`;
const ACTION_SNIPPET_DOCKER_RUNS = `inputs:
name:
description: 'Name to greet'
required: false
default: 'World'
outputs:
greeting:
description: 'The greeting message'
runs:
# For more on Docker actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
using: docker
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
image: '\${1:docker://alpine:3.20}'
env:
INPUT_NAME: \\\${{ inputs.name }}
entrypoint: '\${2:sh}'
args:
- -c
- |
GREETING="Hello $INPUT_NAME"
echo "$GREETING"
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
`;
const ACTION_SNIPPET_DOCKER_USING = `# For more on Docker actions, see:
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
using: docker
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
image: '\${1:docker://alpine:3.20}'
entrypoint: '\${2:sh}'
args:
- -c
- echo "Hello World"
`;
/**
* Filters action.yml `runs:` completions based on the `using:` value.
*
* When the user is completing keys under `runs:`:
* - If `using: node20` is set, only show Node.js action keys
* - If `using: composite` is set, only show composite action keys
* - If `using: docker` is set, only show Docker action keys
* - If `using:` is not set, show all keys but prioritize `using` first
*/
export function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] {
// Find the runs mapping from the root
let runsMapping: MappingToken | undefined;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const {key, value} = root.get(i);
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
runsMapping = value;
break;
}
}
}
if (!runsMapping) {
return values;
}
// Check if the runs mapping is in our path (meaning we're completing inside it)
const isInsideRuns = path.some(token => token === runsMapping);
if (!isInsideRuns) {
return values;
}
// Find where runsMapping is in the path
const runsMappingIndex = path.indexOf(runsMapping);
if (runsMappingIndex === -1) {
return values;
}
// Check if there's anything after runsMapping in the path
// If so, we're nested deeper (e.g., inside steps sequence or a step mapping)
if (runsMappingIndex < path.length - 1) {
return values;
}
// Get the using value from the runs mapping
let usingValue: string | undefined;
for (let i = 0; i < runsMapping.count; i++) {
const {key, value} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingValue = value.toString();
break;
}
}
// Determine which keys to allow
let allowedKeys: Set<string>;
if (!usingValue) {
// No using value set - show all keys but prioritize "using"
return values.map(v => {
if (v.label.toLowerCase() === "using") {
return {...v, sortText: "0_using"}; // Sort first
}
return v;
});
} else if (usingValue.match(/^node\d+$/i)) {
allowedKeys = ACTION_NODE_KEYS;
} else if (usingValue.toLowerCase() === "composite") {
allowedKeys = ACTION_COMPOSITE_KEYS;
} else if (usingValue.toLowerCase() === "docker") {
allowedKeys = ACTION_DOCKER_KEYS;
} else {
// Unknown using value - show all
return values;
}
// Filter to only allowed keys
return values.filter(v => allowedKeys.has(v.label.toLowerCase()));
}
/**
* Gets action scaffolding snippet completions for action.yml files.
*
* Returns snippet completions when `runs.using` is not present, offering
* three action types: Node.js, Composite, and Docker.
*
* Three variants per type:
* - "_FULL": Full scaffold with name, description, inputs, outputs, and runs
* - "_RUNS": Inputs, outputs, and runs (when name/description already exists)
* - "_USING": Minimal runs content (when inside `runs:` mapping)
*
* Which variant is shown depends on context:
* - Inside `runs:` mapping → "_USING" variants
* - At root with name/description → "_RUNS" variants
* - At root without name/description → "_FULL" variants
*/
export function getActionScaffoldingSnippets(
root: TemplateToken | undefined,
path: TemplateToken[],
position: Position
): CompletionItem[] {
// Get the runs mapping from the root, if it exists
let runsMapping: MappingToken | undefined;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const {key, value} = root.get(i);
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
runsMapping = value;
break;
}
}
}
// Check if runs.using already exists - if so, no scaffolding needed
if (runsMapping) {
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
return [];
}
}
}
// Show "_USING" variants directly inside `runs`
const runsMappingIndex = runsMapping ? path.indexOf(runsMapping) : -1;
const isDirectlyInsideRuns = runsMappingIndex !== -1 && runsMappingIndex === path.length - 1;
if (isDirectlyInsideRuns) {
return [
createSnippetCompletion(
"Node.js Action",
"Scaffold a Node.js action",
ACTION_SNIPPET_NODEJS_USING,
position,
"1_nodejs"
),
createSnippetCompletion(
"Composite Action",
"Scaffold a composite action",
ACTION_SNIPPET_COMPOSITE_USING,
position,
"2_composite"
),
createSnippetCompletion(
"Docker Action",
"Scaffold a Docker action",
ACTION_SNIPPET_DOCKER_USING,
position,
"3_docker"
)
];
}
// Not at root or `runs` already exists?
const isAtRoot = path.length === 0 || (path.length === 1 && path[0] === root);
if (!isAtRoot || runsMapping) {
return [];
}
// Determine which variant to show based on existing root keys
let hasNameOrDescription = false;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const keyStr = root.get(i).key.toString().toLowerCase();
if (keyStr === "name" || keyStr === "description") {
hasNameOrDescription = true;
break;
}
}
}
// Show "_RUNS" variants (inputs, outputs, and runs block)
if (hasNameOrDescription) {
return [
createSnippetCompletion(
"Node.js Action",
"Scaffold a Node.js action",
ACTION_SNIPPET_NODEJS_RUNS,
position,
"1_nodejs"
),
createSnippetCompletion(
"Composite Action",
"Scaffold a composite action",
ACTION_SNIPPET_COMPOSITE_RUNS,
position,
"2_composite"
),
createSnippetCompletion(
"Docker Action",
"Scaffold a Docker action",
ACTION_SNIPPET_DOCKER_RUNS,
position,
"3_docker"
)
];
}
// Show "_FULL" variants (complete scaffold)
return [
createSnippetCompletion(
"Node.js Action",
"Scaffold a complete Node.js action",
ACTION_SNIPPET_NODEJS_FULL,
position,
"1_nodejs"
),
createSnippetCompletion(
"Composite Action",
"Scaffold a complete composite action",
ACTION_SNIPPET_COMPOSITE_FULL,
position,
"2_composite"
),
createSnippetCompletion(
"Docker Action",
"Scaffold a complete Docker action",
ACTION_SNIPPET_DOCKER_FULL,
position,
"3_docker"
)
];
}
/**
* Creates a snippet completion item.
*/
function createSnippetCompletion(
label: string,
description: string,
snippetText: string,
position: Position,
sortText: string
): CompletionItem {
return {
label,
kind: CompletionItemKind.Snippet,
documentation: {
kind: "markdown",
value: description
},
insertTextFormat: InsertTextFormat.Snippet,
sortText,
textEdit: TextEdit.insert(position, snippetText)
};
}
+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 - "
});
});
+58 -13
View File
@@ -1,4 +1,4 @@
import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions";
import {complete as completeExpression, DescriptionDictionary, FeatureFlags} 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";
@@ -16,6 +16,7 @@ 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 {filterActionRunsCompletions, getActionScaffoldingSnippets} from "./complete-action.js";
import {ContextProviderConfig} from "./context-providers/config.js";
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
import {ActionContext, getActionContext} from "./context/action-context.js";
@@ -54,6 +55,7 @@ export type CompletionConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
fileProvider?: FileProvider;
featureFlags?: FeatureFlags;
};
export async function complete(
@@ -137,7 +139,7 @@ export async function complete(
const indentString = " ".repeat(indentation.tabSize);
// YAML key/value completions
const values = await getValues(
let values = await getValues(
token,
keyToken,
parent,
@@ -147,10 +149,21 @@ export async function complete(
schema
);
// Filter action.yml `runs:` completions based on `using:` value
if (isAction && parsedTemplate.value) {
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
}
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
values.push(...escapeHatches);
// Get action scaffolding snippets if applicable
let actionSnippets: CompletionItem[] = [];
if (isAction && config?.featureFlags?.isEnabled("actionScaffoldingSnippets")) {
actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position);
}
// 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;
@@ -179,7 +192,7 @@ export async function complete(
}
// Convert values to LSP CompletionItems
return values.map(value => {
const completionItems = values.map(value => {
const newText = value.insertText || value.label;
// Escape hatches provide their own textEdit to restructure the YAML
@@ -192,6 +205,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,11 +221,15 @@ export async function complete(
value: value.description
},
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
textEdit
textEdit,
additionalTextEdits
};
return item;
});
// Add action scaffolding snippets if available
return [...completionItems, ...actionSnippets];
}
/**
@@ -388,9 +411,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 +433,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 +450,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 () => {
+1 -2
View File
@@ -110,8 +110,7 @@ jobs:
`;
const result = await hover(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Cron description is now shown via diagnostics, not hover
expect(result?.contents).toEqual("");
expect(result?.contents).toEqual("Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00");
});
it("on a cron mapping key", async () => {
+13
View File
@@ -2,6 +2,8 @@ import {data, DescriptionDictionary, Parser} from "@actions/expressions";
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
import {Lexer} from "@actions/expressions/lexer";
import {parseAction} from "@actions/workflow-parser/actions/action-parser";
import {isString} from "@actions/workflow-parser";
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
@@ -134,6 +136,17 @@ export async function hover(document: TextDocument, position: Position, config?:
// Non-expression hover: show the schema description for the YAML key or value
info(`Calculating hover for token with definition ${hoverToken.definition.key}`);
// Check for cron expression hover
if (isString(hoverToken) && hoverToken.definition.key === "cron-pattern") {
const cronDescription = getCronDescription(hoverToken.value);
if (cronDescription) {
return {
contents: cronDescription,
range: mapRange(hoverToken.range)
};
}
}
let description: string;
if (!isAction && tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
// Reusable workflow call: fetch the called workflow's input descriptions
+1 -1
View File
@@ -1,4 +1,4 @@
export {complete} from "./complete.js";
export {complete, CompletionConfig} from "./complete.js";
export {ContextProviderConfig} from "./context-providers/config.js";
export {documentLinks} from "./document-links.js";
export {hover} from "./hover.js";
+180
View File
@@ -347,4 +347,184 @@ runs:
expect(diagnostics).toEqual([]);
});
});
describe("invalid key combinations based on using type", () => {
it("reports error for node20 action with steps", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - node20 with steps
runs:
using: node20
main: index.js
steps:
- run: echo "hello"
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.length).toBeGreaterThan(0);
// Schema reports "Unexpected value 'steps'" for invalid keys
expect(diagnostics.some(d => d.message.includes("steps"))).toBe(true);
});
it("reports error for composite action with main", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - composite with main
runs:
using: composite
steps:
- run: echo "hello"
shell: bash
main: index.js
`);
const diagnostics = await validate(doc);
expect(diagnostics.length).toBeGreaterThan(0);
// Schema reports "Unexpected value 'main'" for invalid keys
expect(diagnostics.some(d => d.message.includes("main"))).toBe(true);
});
it("reports error for docker action with steps", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - docker with steps
runs:
using: docker
image: Dockerfile
steps:
- run: echo "hello"
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.length).toBeGreaterThan(0);
// Schema reports "Unexpected value 'steps'" for invalid keys
expect(diagnostics.some(d => d.message.includes("steps"))).toBe(true);
});
it("reports error for docker action with main", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - docker with main
runs:
using: docker
image: Dockerfile
main: index.js
`);
const diagnostics = await validate(doc);
expect(diagnostics.length).toBeGreaterThan(0);
// Schema reports "Unexpected value 'main'" for invalid keys
expect(diagnostics.some(d => d.message.includes("main"))).toBe(true);
});
it("reports error for node20 action missing main", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - node20 without main
runs:
using: node20
pre: setup.js
`);
const diagnostics = await validate(doc);
expect(diagnostics.length).toBeGreaterThan(0);
expect(diagnostics.some(d => d.message.includes("main"))).toBe(true);
});
it("reports error for node24 action missing main", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - node24 without main
runs:
using: node24
pre: setup.js
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message === "'main' is required for Node.js actions (using: node24)")).toBe(true);
// Should NOT have duplicate schema error
expect(diagnostics.filter(d => d.message.includes("main")).length).toBe(1);
});
it("reports error for node24 action with only using (no narrowing key)", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - node24 without main
runs:
using: node24
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message === "'main' is required for Node.js actions (using: node24)")).toBe(true);
// Should NOT have the generic "not enough info" schema error
expect(diagnostics.some(d => d.message.includes("There's not enough info"))).toBe(false);
});
it("reports error for composite action missing steps", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - composite without steps
runs:
using: composite
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message === "'steps' is required for composite actions (using: composite)")).toBe(
true
);
// Should NOT have duplicate schema error
expect(diagnostics.some(d => d.message.includes("There's not enough info"))).toBe(false);
});
it("reports error for docker action missing image", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - docker without image
runs:
using: docker
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message === "'image' is required for Docker actions (using: docker)")).toBe(true);
// Should NOT have duplicate schema error
expect(diagnostics.some(d => d.message.includes("There's not enough info"))).toBe(false);
});
it("reports error for docker action with entrypoint but missing image", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - docker without image
runs:
using: docker
entrypoint: /entrypoint.sh
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message === "'image' is required for Docker actions (using: docker)")).toBe(true);
// Should NOT have duplicate "Required property is missing: image" schema error
expect(diagnostics.filter(d => d.message.includes("image")).length).toBe(1);
});
it("lets schema handle missing using", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - no using
runs:
main: index.js
`);
const diagnostics = await validate(doc);
// Should have schema error about not enough info or unexpected value
expect(diagnostics.length).toBeGreaterThan(0);
// Should NOT have custom validation error (can't determine action type)
expect(diagnostics.some(d => d.message.includes("is required for"))).toBe(false);
});
it("lets schema handle invalid using value", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid - bad using value
runs:
using: not-supported
main: index.js
`);
const diagnostics = await validate(doc);
// Should have schema error about unexpected value
expect(diagnostics.length).toBeGreaterThan(0);
// Should NOT have custom validation error (unknown action type)
expect(diagnostics.some(d => d.message.includes("is required for"))).toBe(false);
expect(diagnostics.some(d => d.message.includes("is not valid for"))).toBe(false);
});
});
});
+167 -2
View File
@@ -5,8 +5,10 @@
import {isMapping} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {TemplateValidationError} from "@actions/workflow-parser/templates/template-validation-error";
import {File} from "@actions/workflow-parser/workflows/file";
import {TextDocument} from "vscode-languageserver-textdocument";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
@@ -16,6 +18,31 @@ import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cac
import {validateActionReference} from "./validate-action-reference.js";
import {ValidationConfig} from "./validate.js";
/**
* Valid keys for each action type under the `runs:` section.
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
*/
const NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
const COMPOSITE_KEYS = new Set(["using", "steps"]);
const DOCKER_KEYS = new Set([
"using",
"image",
"args",
"env",
"entrypoint",
"pre-entrypoint",
"pre-if",
"post-entrypoint",
"post-if"
]);
/**
* Required keys for each action type (besides 'using').
*/
const NODE_REQUIRED_KEYS = ["main"];
const COMPOSITE_REQUIRED_KEYS = ["steps"];
const DOCKER_REQUIRED_KEYS = ["image"];
/**
* Validates an action.yml file
*
@@ -38,8 +65,16 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
return [];
}
// Map parser errors to diagnostics
for (const err of result.context.errors.getErrors()) {
// Get schema errors
const schemaErrors = result.context.errors.getErrors();
// Run custom runs key validation, which also filters redundant schema errors in place
if (result.value) {
diagnostics.push(...validateRunsKeysAndFilterErrors(result.value, schemaErrors));
}
// Map remaining schema errors to diagnostics
for (const err of schemaErrors) {
const range = mapRange(err.range);
// Determine severity based on error type
@@ -102,3 +137,133 @@ function findStepsSequence(root: TemplateToken): SequenceToken | undefined {
}
return undefined;
}
/**
* Validates that the keys under `runs:` are valid for the specified `using:` type.
* Also filters out schema errors (in place) that this validation replaces with more specific messages.
*/
function validateRunsKeysAndFilterErrors(
root: TemplateToken,
schemaErrors: TemplateValidationError[] // mutated: redundant errors are removed
): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
// Find the runs mapping from the root
let runsMapping: MappingToken | undefined;
if (root instanceof MappingToken) {
for (let i = 0; i < root.count; i++) {
const {key, value} = root.get(i);
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
runsMapping = value;
break;
}
}
}
if (!runsMapping) {
return diagnostics;
}
// Get the using value from the runs mapping
let usingValue: string | undefined;
for (let i = 0; i < runsMapping.count; i++) {
const {key, value} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingValue = value.toString();
break;
}
}
if (!usingValue) {
return diagnostics; // No using value, let schema validation handle it
}
// Determine allowed keys, required keys, and action type name
let allowedKeys: Set<string>;
let requiredKeys: string[];
let actionType: string;
if (usingValue.match(/^node\d+$/i)) {
allowedKeys = NODE_KEYS;
requiredKeys = NODE_REQUIRED_KEYS;
actionType = "Node.js";
} else if (usingValue.toLowerCase() === "composite") {
allowedKeys = COMPOSITE_KEYS;
requiredKeys = COMPOSITE_REQUIRED_KEYS;
actionType = "composite";
} else if (usingValue.toLowerCase() === "docker") {
allowedKeys = DOCKER_KEYS;
requiredKeys = DOCKER_REQUIRED_KEYS;
actionType = "Docker";
} else {
return diagnostics; // Unknown type, let schema validation handle it
}
// Get all present keys
const presentKeys = new Set<string>();
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
presentKeys.add(key.toString().toLowerCase());
}
// Check for invalid keys
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
const keyStr = key.toString().toLowerCase();
if (!allowedKeys.has(keyStr)) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(key.range),
message: `'${key.toString()}' is not valid for ${actionType} actions (using: ${usingValue})`
});
}
}
// Check for missing required keys
for (const requiredKey of requiredKeys) {
if (!presentKeys.has(requiredKey)) {
// Find the 'using' key to report the error location
let usingKeyRange = runsMapping.range;
for (let i = 0; i < runsMapping.count; i++) {
const {key} = runsMapping.get(i);
if (key.toString().toLowerCase() === "using") {
usingKeyRange = key.range;
break;
}
}
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange(usingKeyRange),
message: `'${requiredKey}' is required for ${actionType} actions (using: ${usingValue})`
});
}
}
// Remove schema errors that we're replacing with more specific messages (mutate in place)
for (let i = schemaErrors.length - 1; i >= 0; i--) {
const err = schemaErrors[i];
// Keep errors not at the runs section start
if (
err.range?.start.line !== runsMapping.range?.start.line ||
err.range?.start.column !== runsMapping.range?.start.column
) {
continue;
}
// Check if this is an error we're replacing
const isOneOfAmbiguity = err.rawMessage.startsWith("There's not enough info to determine");
const isRequiredKey = /^Required property is missing: (main|steps|image)$/.test(err.rawMessage);
if (!isOneOfAmbiguity && !isRequiredKey) {
continue; // Keep errors we're not replacing
}
// Remove only if we have custom diagnostics for this
if (diagnostics.length > 0) {
schemaErrors.splice(i, 1);
}
}
return diagnostics;
}
@@ -0,0 +1,199 @@
/**
* Format string validation for format() function calls.
* Port of Go's format_validator.go from actions-workflow-parser.
*/
import {Expr, FunctionCall, Literal, Binary, Unary, Logical, Grouping, IndexAccess} from "@actions/expressions/ast";
import {Kind} from "@actions/expressions/data/expressiondata";
/**
* Error types for format string validation
*/
export type FormatStringError =
| {type: "invalid-syntax"; message: string}
| {type: "arg-count-mismatch"; expected: number; provided: number};
/**
* Validates a format string and returns the maximum placeholder index.
* Port of Go's validateFormatString from format_validator.go.
*
* @param formatString The format string to validate
* @returns { valid: boolean, maxArgIndex: number } where maxArgIndex is -1 if no placeholders
*/
export function validateFormatString(formatString: string): {valid: boolean; maxArgIndex: number} {
let maxIndex = -1;
let i = 0;
while (i < formatString.length) {
// Find next left brace
let lbrace = -1;
for (let j = i; j < formatString.length; j++) {
if (formatString[j] === "{") {
lbrace = j;
break;
}
}
// Find next right brace
let rbrace = -1;
for (let j = i; j < formatString.length; j++) {
if (formatString[j] === "}") {
rbrace = j;
break;
}
}
// No more braces
if (lbrace < 0 && rbrace < 0) {
break;
}
// Left brace comes first (or only left brace exists)
if (lbrace >= 0 && (rbrace < 0 || lbrace < rbrace)) {
// Check if it's escaped
if (lbrace + 1 < formatString.length && formatString[lbrace + 1] === "{") {
// Escaped left brace
i = lbrace + 2;
continue;
}
// This is a placeholder opening - find the closing brace
rbrace = -1;
for (let j = lbrace + 1; j < formatString.length; j++) {
if (formatString[j] === "}") {
rbrace = j;
break;
}
}
if (rbrace < 0) {
// Missing closing brace
return {valid: false, maxArgIndex: -1};
}
// Validate placeholder content (must be digits only)
if (rbrace === lbrace + 1) {
// Empty placeholder {}
return {valid: false, maxArgIndex: -1};
}
// Parse the index and validate it's all digits
let index = 0;
for (let j = lbrace + 1; j < rbrace; j++) {
const c = formatString[j];
if (c < "0" || c > "9") {
// Non-numeric character
return {valid: false, maxArgIndex: -1};
}
index = index * 10 + (c.charCodeAt(0) - "0".charCodeAt(0));
}
if (index > maxIndex) {
maxIndex = index;
}
i = rbrace + 1;
continue;
}
// Right brace comes first (or only right brace exists)
// Check if it's escaped
if (rbrace + 1 < formatString.length && formatString[rbrace + 1] === "}") {
// Escaped right brace
i = rbrace + 2;
continue;
}
// Unescaped right brace outside of placeholder
return {valid: false, maxArgIndex: -1};
}
return {valid: true, maxArgIndex: maxIndex};
}
/**
* Walks an expression AST to find and validate all format() function calls.
*
* @param expr The expression AST to validate
* @returns Array of validation errors found
*/
export function validateFormatCalls(expr: Expr): FormatStringError[] {
const errors: FormatStringError[] = [];
const stack: Expr[] = [expr];
while (stack.length > 0) {
const node = stack.pop();
if (!node) {
continue;
}
if (node instanceof FunctionCall) {
if (node.functionName.lexeme.toLowerCase() === "format") {
const error = validateSingleFormatCall(node);
if (error) {
errors.push(error);
}
}
// Push args for further processing (to find nested format calls)
for (const arg of node.args) {
stack.push(arg);
}
} else if (node instanceof Binary) {
stack.push(node.left, node.right);
} else if (node instanceof Unary) {
stack.push(node.expr);
} else if (node instanceof Logical) {
for (const arg of node.args) {
stack.push(arg);
}
} else if (node instanceof Grouping) {
stack.push(node.group);
} else if (node instanceof IndexAccess) {
stack.push(node.expr, node.index);
}
// Literal, ContextAccess - no children to process
}
return errors;
}
/**
* Validates a single format() function call.
*
* @param fc The FunctionCall AST node
* @returns Validation error if found, undefined if valid
*/
function validateSingleFormatCall(fc: FunctionCall): FormatStringError | undefined {
// Must have at least one argument (the format string)
if (fc.args.length < 1) {
return undefined;
}
// First argument must be a string literal
const firstArg = fc.args[0];
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== Kind.String) {
return undefined; // Can't validate dynamic format strings
}
const formatString = firstArg.literal.coerceString();
const numArgs = fc.args.length - 1; // Subtract 1 for format string itself
const {valid, maxArgIndex} = validateFormatString(formatString);
if (!valid) {
return {
type: "invalid-syntax",
message: "Format string has invalid syntax (missing closing brace, unescaped braces, or invalid placeholder)"
};
}
if (maxArgIndex >= numArgs) {
return {
type: "arg-count-mismatch",
expected: maxArgIndex + 1, // Convert 0-based index to count
provided: numArgs
};
}
return undefined;
}
@@ -0,0 +1,835 @@
import {FeatureFlags} from "@actions/expressions";
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {registerLogger} from "./log.js";
import {createDocument} from "./test-utils/document.js";
import {TestLogger} from "./test-utils/logger.js";
import {clearCache} from "./utils/workflow-cache.js";
import {validate, ValidationConfig} from "./validate.js";
registerLogger(new TestLogger());
const configWithFlag: ValidationConfig = {
featureFlags: new FeatureFlags({blockScalarChompingWarning: true})
};
beforeEach(() => {
clearCache();
});
describe("block scalar chomping - warning cases", () => {
describe("step-level env values", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: |
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("does not warn with keep chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: |+
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn with strip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: |-
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("uses > indicator in warning message for folded scalars", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: >
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '>' implicitly adds a trailing newline that may be unintentional. Use '>-' to remove it, or '>+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for plain string env value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: |
hello world
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("job-level env values", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
env:
MY_VAR: |
some value
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("workflow-level env values", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
env:
GLOBAL_VAR: |
some value
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("container env values", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
container:
image: node:18
env:
CONTAINER_VAR: |
some value
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("service container env values", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis
env:
REDIS_PASSWORD: |
secret123
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("action input (with)", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
script: |
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("does not warn with keep chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
script: |+
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn with strip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
script: |-
\${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
});
describe("reusable workflow inputs (with)", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
call-workflow:
uses: ./.github/workflows/reusable.yml
with:
my-input: |
some value
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("reusable workflow secrets", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
call-workflow:
uses: ./.github/workflows/reusable.yml
secrets:
my-secret: |
\${{ secrets.TOKEN }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("job outputs", () => {
it("warns with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
outputs:
my_output: |
\${{ steps.test.outputs.value }}
steps:
- id: test
run: echo "value=test" >> $GITHUB_OUTPUT
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("does not warn with strip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
outputs:
my_output: |-
\${{ steps.test.outputs.value }}
steps:
- id: test
run: echo "value=test" >> $GITHUB_OUTPUT
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
});
describe("matrix values", () => {
it("warns for matrix vector value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
config:
- |
value1
- value2
steps:
- run: echo \${{ matrix.config }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("does not warn with strip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
config:
- |-
value1
- value2
steps:
- run: echo \${{ matrix.config }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("warns for matrix include value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest]
include:
- os: |
windows-latest
special: true
steps:
- run: echo \${{ matrix.os }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for matrix exclude value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [16, 18]
exclude:
- os: |
windows-latest
node: 16
steps:
- run: echo \${{ matrix.os }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for deeply nested matrix value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
config:
- foo:
bar: |
baz
steps:
- run: echo \${{ matrix.config }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for deeply nested matrix include value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest]
include:
- os: ubuntu-latest
config:
nested: |
value
steps:
- run: echo \${{ matrix.config }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for deeply nested matrix exclude value with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
exclude:
- os: windows-latest
config:
nested: |
value
steps:
- run: echo \${{ matrix.os }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
describe("concurrency", () => {
it("warns for concurrency string with clip chomping", async () => {
const input = `
on: push
concurrency: |
my-group-\${{ github.ref }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("does not warn for concurrency with strip chomping", async () => {
const input = `
on: push
concurrency: |-
my-group-\${{ github.ref }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("warns for concurrency.group with clip chomping", async () => {
const input = `
on: push
concurrency:
group: |
my-group-\${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
it("warns for job-level concurrency with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
concurrency: |
job-group-\${{ github.ref }}
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result).toContainEqual(
expect.objectContaining({
message:
"Block scalar '|' implicitly adds a trailing newline that may be unintentional. Use '|-' to remove it, or '|+' to explicitly keep it.",
code: "block-scalar-chomping",
severity: DiagnosticSeverity.Warning
})
);
});
});
});
describe("block scalar chomping - no warning cases", () => {
describe("fields trimmed server-side", () => {
it("does not warn for job-if with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
if: |
github.ref == 'refs/heads/main'
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for step-if with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo done
if: |
github.ref == 'refs/heads/main'
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for runs-on with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: |
ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for job name with clip chomping", async () => {
const input = `
on: push
jobs:
build:
name: |
My Job
runs-on: ubuntu-latest
steps:
- run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for step name with clip chomping", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: |
My Step
run: echo done
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
});
describe("run field (intentionally allowed)", () => {
it("does not warn for step run field", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
echo hello
echo world
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for run field with expression", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
echo \${{ github.ref }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
});
describe("non-block scalars", () => {
it("does not warn for quoted strings", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: "hello world"
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for flow scalars", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: hello world
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
it("does not warn for inline expressions", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo $VAR
env:
VAR: \${{ matrix.value }}
`;
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
expect(result.filter(d => d.code === "block-scalar-chomping")).toEqual([]);
});
});
});
@@ -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"
})
);
});
});
});
@@ -0,0 +1,273 @@
import {DiagnosticSeverity} from "vscode-languageserver-types";
import {createDocument} from "./test-utils/document.js";
import {validate} from "./validate.js";
import {clearCache} from "./utils/workflow-cache.js";
import {validateFormatString} from "./validate-format-string.js";
beforeEach(() => {
clearCache();
});
describe("format string validation", () => {
describe("validateFormatString unit tests", () => {
it("returns valid for simple placeholder", () => {
const result = validateFormatString("{0}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
it("returns valid for multiple placeholders", () => {
const result = validateFormatString("{0} {1} {2}");
expect(result).toEqual({valid: true, maxArgIndex: 2});
});
it("returns valid for text with placeholder", () => {
const result = validateFormatString("hello {0} world");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
it("returns valid for escaped left braces", () => {
const result = validateFormatString("{{0}} {0}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
it("returns valid for escaped right braces", () => {
const result = validateFormatString("{0}}}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
it("returns valid for no placeholders", () => {
const result = validateFormatString("hello world");
expect(result).toEqual({valid: true, maxArgIndex: -1});
});
it("returns invalid for missing closing brace", () => {
const result = validateFormatString("{0");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});
it("returns invalid for empty placeholder", () => {
const result = validateFormatString("{}");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});
it("returns invalid for non-numeric placeholder", () => {
const result = validateFormatString("{abc}");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});
it("returns invalid for unescaped closing brace", () => {
const result = validateFormatString("text } more");
expect(result).toEqual({valid: false, maxArgIndex: -1});
});
it("handles out-of-order placeholders", () => {
const result = validateFormatString("{2} {0} {1}");
expect(result).toEqual({valid: true, maxArgIndex: 2});
});
it("handles repeated placeholders", () => {
const result = validateFormatString("{0} {0} {0}");
expect(result).toEqual({valid: true, maxArgIndex: 0});
});
});
describe("InvalidFormatString workflow validation", () => {
it("errors on missing closing brace", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{0', github.event_name) }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
code: "invalid-format-string",
severity: DiagnosticSeverity.Error
})
);
});
it("errors on empty braces", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{}', github.event_name) }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
code: "invalid-format-string"
})
);
});
it("errors on non-numeric placeholder", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{abc}', github.event_name) }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
code: "invalid-format-string"
})
);
});
it("allows valid format strings", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{0} {1}', github.event_name, github.ref) }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "invalid-format-string"
})
);
});
it("allows escaped braces", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{{0}} {0}', github.event_name) }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "invalid-format-string"
})
);
});
});
describe("FormatArgCountMismatch workflow validation", () => {
it("errors when placeholder exceeds arg count", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{2}', 'arg0', 'arg1') }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
code: "format-arg-count-mismatch",
severity: DiagnosticSeverity.Error
})
);
});
it("errors when referencing arg 0 with no args", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{0}') }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual(
expect.objectContaining({
code: "format-arg-count-mismatch"
})
);
});
it("allows when arg count matches", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{0} {1} {2}', 'a', 'b', 'c') }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "format-arg-count-mismatch"
})
);
});
it("handles no placeholders correctly", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('hello world') }}
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).not.toContainEqual(
expect.objectContaining({
code: "format-arg-count-mismatch"
})
);
});
it("skips validation for dynamic format strings", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format(env.FORMAT_STRING, 'arg') }}
`;
const result = await validate(createDocument("wf.yaml", input));
// Should not have format errors since we can't validate dynamic strings
expect(result).not.toContainEqual(
expect.objectContaining({
code: "invalid-format-string"
})
);
expect(result).not.toContainEqual(
expect.objectContaining({
code: "format-arg-count-mismatch"
})
);
});
it("validates nested format calls", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo \${{ format('{0}', format('{2}', 'a')) }}
`;
const result = await validate(createDocument("wf.yaml", input));
// The inner format call has an error
expect(result).toContainEqual(
expect.objectContaining({
code: "format-arg-count-mismatch"
})
);
});
});
});
+125 -6
View File
@@ -1,4 +1,4 @@
import {Lexer, Parser, data} from "@actions/expressions";
import {FeatureFlags, Lexer, Parser, data} from "@actions/expressions";
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
@@ -27,6 +27,7 @@ import {mapRange} from "./utils/range.js";
import {getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
import {validateActionReference} from "./validate-action-reference.js";
import {validateAction} from "./validate-action.js";
import {validateFormatCalls} from "./validate-format-string.js";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
import {defaultValueProviders} from "./value-providers/default.js";
@@ -38,6 +39,7 @@ export type ValidationConfig = {
contextProviderConfig?: ContextProviderConfig;
actionsMetadataProvider?: ActionsMetadataProvider;
fileProvider?: FileProvider;
featureFlags?: FeatureFlags;
};
export type ActionsMetadataProvider = {
@@ -84,7 +86,7 @@ async function validateWorkflow(textDocument: TextDocument, config?: ValidationC
});
// Validate expressions and value providers
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config);
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config, config?.featureFlags);
}
// For now map parser errors directly to diagnostics
@@ -108,9 +110,10 @@ async function additionalValidations(
documentUri: URI,
template: WorkflowTemplate,
root: TemplateToken,
config?: ValidationConfig
config?: ValidationConfig,
featureFlags?: FeatureFlags
) {
for (const [parent, token, key] of TemplateToken.traverse(root)) {
for (const [parent, token, key, ancestors] of TemplateToken.traverse(root)) {
// If the token is a value in a pair, use the key definition for validation
// If the token has a parent (map, sequence, etc), use this definition for validation
const validationToken = key || parent || token;
@@ -128,7 +131,12 @@ async function additionalValidations(
);
}
// If this is a job-if, step-if, or snapshot-if field (which are strings that should be treated as expressions), validate it
// Validate block scalar chomping for expressions and strings
if (featureFlags?.isEnabled("blockScalarChompingWarning")) {
validateBlockScalarChomping(diagnostics, token, parent, key, ancestors);
}
// `if` conditions allow omitting ${{ }}, so validate strings in these fields as expressions
const definitionKey = token.definition?.key;
if (
isString(token) &&
@@ -148,7 +156,9 @@ async function additionalValidations(
finalCondition,
token.definitionInfo,
undefined,
token.source
token.source,
undefined,
token.blockScalarHeader
);
await validateExpression(
@@ -735,6 +745,28 @@ async function validateExpression(
continue;
}
// Validate format() function calls
const formatErrors = validateFormatCalls(expr);
for (const formatError of formatErrors) {
if (formatError.type === "invalid-syntax") {
diagnostics.push({
message: `Invalid format string: ${formatError.message}`,
range: mapRange(expression.range),
severity: DiagnosticSeverity.Error,
code: "invalid-format-string"
});
} else if (formatError.type === "arg-count-mismatch") {
diagnostics.push({
message: `Format string references argument {${formatError.expected - 1}} but only ${
formatError.provided
} argument(s) provided`,
range: mapRange(expression.range),
severity: DiagnosticSeverity.Error,
code: "format-arg-count-mismatch"
});
}
}
const context = await getWorkflowExpressionContext(
namedContexts,
contextProviderConfig,
@@ -822,3 +854,90 @@ function getStaticConcurrencyGroup(token: TemplateToken | undefined): StringToke
return undefined;
}
/**
* Validates YAML block scalar chomping.
*
* Block scalars (| and >) implicitly add a trailing newline by default ("clip" chomping).
* This is often unintended by the workflow author and can cause unexpected behavior.
* This function warns on certain fields when clip chomping is used (implicit trailing newline)
* and suggests they explicitly use strip (|-) or keep (|+) to clarify intent.
*
* Only specific fields are validated - those where trailing newlines may cause
* issues but aren't automatically trimmed server-side. For example env, inputs, outputs, etc.
*
* Skipped fields:
* - run: Multi-line scripts commonly have trailing newlines
* - Fields trimmed server-side: name, uses, shell, if, etc.
*/
function validateBlockScalarChomping(
diagnostics: Diagnostic[],
token: TemplateToken,
parent: TemplateToken | undefined,
key: TemplateToken | undefined,
ancestors: TemplateToken[]
): void {
// Not an expression or string?
if (!isBasicExpression(token) && !isString(token)) {
return;
}
// Not a block scalar?
const header = token.blockScalarHeader;
if (!header) {
return;
}
// Not "clip" chomp style?
if (header.includes("+") || header.includes("-")) {
return;
}
// Check if we should warn
let shouldWarn = false;
const parentDefinitionName = parent?.definition?.key;
const tokenDefinitionName = token.definition?.key;
const keyName = key && isString(key) ? key.value : undefined;
if (
parentDefinitionName &&
[
"workflow-env",
"job-env",
"step-env",
"container-env",
"step-with",
"job-outputs",
"workflow-job-with",
"workflow-job-secrets"
].includes(parentDefinitionName)
) {
// env, with, outputs, or secrets fields
shouldWarn = true;
} else if (
ancestors.some(ancestor => {
const ancestorKey = ancestor.definition?.key;
return ancestorKey === "matrix" || ancestorKey === "matrix-filter" || ancestorKey === "matrix-filter-item";
})
) {
// Matrix values (vectors, include, exclude)
shouldWarn = true;
} else if (tokenDefinitionName && ["workflow-concurrency", "job-concurrency"].includes(tokenDefinitionName)) {
// Concurrency shorthand
shouldWarn = true;
} else if (keyName === "group" && parentDefinitionName === "concurrency-mapping") {
// Concurrency group field
shouldWarn = true;
}
if (!shouldWarn) {
return;
}
const blockIndicator = header.startsWith("|") ? "|" : ">";
diagnostics.push({
message: `Block scalar '${blockIndicator}' implicitly adds a trailing newline that may be unintentional. Use '${blockIndicator}-' to remove it, or '${blockIndicator}+' to explicitly keep it.`,
range: mapRange(token.range),
severity: DiagnosticSeverity.Warning,
code: "block-scalar-chomping"
});
}
@@ -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.35"
}
+9 -9
View File
@@ -136,7 +136,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.32",
"version": "0.3.35",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.32",
"version": "0.3.35",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.32",
"@actions/workflow-parser": "^0.3.32",
"@actions/languageservice": "^0.3.35",
"@actions/workflow-parser": "^0.3.35",
"@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.35",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.32",
"@actions/workflow-parser": "^0.3.32",
"@actions/expressions": "^0.3.35",
"@actions/workflow-parser": "^0.3.35",
"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.35",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.32",
"@actions/expressions": "^0.3.35",
"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.35",
"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.35",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+1
View File
@@ -267,6 +267,7 @@
},
"main": {
"type": "non-empty-string",
"required": true,
"description": "The file that contains your action code. The runtime specified in using executes this file.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsmain)"
},
"pre": {
+351
View File
@@ -201,4 +201,355 @@ jobs:
throw new Error("expected if to be a string (will be converted to expression later)");
}
});
describe("Block scalar chomp style preservation", () => {
it("preserves clip chomping (|) for literal block scalar", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
env:
TEST: |
\${{ github.event_name }}
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const env = build.get(1).value.assertMapping("env");
const testToken = env.get(0).value;
if (!isBasicExpression(testToken)) {
throw new Error("expected TEST to be a basic expression");
}
expect(testToken.blockScalarHeader).toBe("|");
});
it("preserves strip chomping (|-) for literal block scalar", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
env:
TEST: |-
\${{ github.event_name }}
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const env = build.get(1).value.assertMapping("env");
const testToken = env.get(0).value;
if (!isBasicExpression(testToken)) {
throw new Error("expected TEST to be a basic expression");
}
expect(testToken.blockScalarHeader).toBe("|-");
});
it("preserves keep chomping (|+) for literal block scalar", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
env:
TEST: |+
\${{ github.event_name }}
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const env = build.get(1).value.assertMapping("env");
const testToken = env.get(0).value;
if (!isBasicExpression(testToken)) {
throw new Error("expected TEST to be a basic expression");
}
expect(testToken.blockScalarHeader).toBe("|+");
});
it("preserves folded clip (>) chomping", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
env:
TEST: >
\${{ github.event_name }}
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const env = build.get(1).value.assertMapping("env");
const testToken = env.get(0).value;
if (!isBasicExpression(testToken)) {
throw new Error("expected TEST to be a basic expression");
}
expect(testToken.blockScalarHeader).toBe(">");
});
it("preserves folded strip (>-) chomping", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
env:
TEST: >-
\${{ github.event_name }}
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const env = build.get(1).value.assertMapping("env");
const testToken = env.get(0).value;
if (!isBasicExpression(testToken)) {
throw new Error("expected TEST to be a basic expression");
}
expect(testToken.blockScalarHeader).toBe(">-");
});
it("preserves with explicit indent (|2)", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
env:
TEST: |2
\${{ github.event_name }}
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const env = build.get(1).value.assertMapping("env");
const testToken = env.get(0).value;
if (!isBasicExpression(testToken)) {
throw new Error("expected TEST to be a basic expression");
}
expect(testToken.blockScalarHeader).toBe("|2");
});
it("preserves with explicit indent and strip (|-2)", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
env:
TEST: |-2
\${{ github.event_name }}
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const env = build.get(1).value.assertMapping("env");
const testToken = env.get(0).value;
if (!isBasicExpression(testToken)) {
throw new Error("expected TEST to be a basic expression");
}
expect(testToken.blockScalarHeader).toBe("|-2");
});
it("handles flow scalars (no chomp info for inline)", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
env:
TEST: \${{ github.event_name }}
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const env = build.get(1).value.assertMapping("env");
const testToken = env.get(0).value;
if (!isBasicExpression(testToken)) {
throw new Error("expected TEST to be a basic expression");
}
expect(testToken.blockScalarHeader).toBeUndefined();
});
it("preserves block scalar info for format expressions with multiple sub-expressions", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
env:
TEST: |
Hello \${{ github.event_name }} World \${{ github.ref }}
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const env = build.get(1).value.assertMapping("env");
const testToken = env.get(0).value;
if (!isBasicExpression(testToken)) {
throw new Error("expected TEST to be a basic expression");
}
// The format expression should preserve the block scalar info
expect(testToken.blockScalarHeader).toBe("|");
});
it("preserves block scalar info on StringToken for isExpression fields", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
if: |
github.event_name == 'push'
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const ifToken = build.get(1).value;
// For isExpression fields without ${{ }}, the token is a StringToken
if (!isString(ifToken)) {
throw new Error("expected if to be a string");
}
expect(ifToken.blockScalarHeader).toBe("|");
});
it("preserves block scalar info on StringToken for isExpression fields with strip", () => {
const result = parseWorkflow(
{
name: "test.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
if: |-
github.event_name == 'push'
steps:
- run: echo hi`
},
nullTrace
);
expect(result.context.errors.getErrors()).toHaveLength(0);
const workflowRoot = result.value!.assertMapping("root")!;
const jobs = workflowRoot.get(1).value.assertMapping("jobs");
const build = jobs.get(0).value.assertMapping("job");
const ifToken = build.get(1).value;
if (!isString(ifToken)) {
throw new Error("expected if to be a string");
}
expect(ifToken.blockScalarHeader).toBe("|-");
});
});
});
@@ -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");
@@ -604,7 +613,9 @@ class TemplateReader {
`format('${format.join("")}'${args.join("")})`,
definitionInfo,
expressionTokens,
raw
raw,
undefined,
token.blockScalarHeader
);
}
@@ -686,7 +697,8 @@ class TemplateReader {
definitionInfo,
undefined,
token.source,
expressionRange
expressionRange,
token.blockScalarHeader
),
error: undefined
};
@@ -24,7 +24,19 @@ export class BasicExpressionToken extends ExpressionToken {
public readonly expressionRange: TokenRange | undefined;
/**
* @param originalExpressions If the basic expression was transformed from individual expressions, these will be the original ones
* The block scalar header (e.g., "|", "|-", "|+", ">", ">-", ">+") if parsed from a YAML block scalar.
*/
public readonly blockScalarHeader: string | undefined;
/**
* @param file The file ID where this token originated
* @param range The range of the entire expression including `${{` and `}}`
* @param expression The expression string without `${{` and `}}` markers
* @param definitionInfo Schema definition info for this token
* @param originalExpressions If transformed from individual expressions (e.g., format()), these are the originals
* @param source The original source string from the YAML
* @param expressionRange The range of just the expression, excluding `${{` and `}}`
* @param blockScalarHeader The block scalar header (e.g., "|", "|-") if parsed from a YAML block scalar
*/
public constructor(
file: number | undefined,
@@ -33,13 +45,15 @@ export class BasicExpressionToken extends ExpressionToken {
definitionInfo: DefinitionInfo | undefined,
originalExpressions: BasicExpressionToken[] | undefined,
source: string | undefined,
expressionRange?: TokenRange | undefined
expressionRange?: TokenRange | undefined,
blockScalarHeader?: string | undefined
) {
super(TokenType.BasicExpression, file, range, undefined, definitionInfo);
this.expr = expression;
this.source = source;
this.originalExpressions = originalExpressions;
this.expressionRange = expressionRange;
this.blockScalarHeader = blockScalarHeader;
}
public get expression(): string {
@@ -55,7 +69,8 @@ export class BasicExpressionToken extends ExpressionToken {
this.definitionInfo,
this.originalExpressions,
this.source,
this.expressionRange
this.expressionRange,
this.blockScalarHeader
)
: new BasicExpressionToken(
this.file,
@@ -64,7 +79,8 @@ export class BasicExpressionToken extends ExpressionToken {
this.definitionInfo,
this.originalExpressions,
this.source,
this.expressionRange
this.expressionRange,
this.blockScalarHeader
);
}
@@ -6,23 +6,26 @@ import {TokenType} from "./types.js";
export class StringToken extends LiteralToken {
public readonly value: string;
public readonly source: string | undefined;
public readonly blockScalarHeader: string | undefined;
public constructor(
file: number | undefined,
range: TokenRange | undefined,
value: string,
definitionInfo: DefinitionInfo | undefined,
source?: string
source?: string,
blockScalarHeader?: string
) {
super(TokenType.String, file, range, definitionInfo);
this.value = value;
this.source = source;
this.blockScalarHeader = blockScalarHeader;
}
public override clone(omitSource?: boolean): TemplateToken {
return omitSource
? new StringToken(undefined, undefined, this.value, this.definitionInfo, this.source)
: new StringToken(this.file, this.range, this.value, this.definitionInfo, this.source);
? new StringToken(undefined, undefined, this.value, this.definitionInfo, this.source, this.blockScalarHeader)
: new StringToken(this.file, this.range, this.value, this.definitionInfo, this.source, this.blockScalarHeader);
}
public override toString(): string {
@@ -1,11 +1,13 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion */
import {nullTrace} from "../../test-utils/null-trace.js";
import {parseWorkflow} from "../../workflows/workflow-parser.js";
import {MappingToken} from "./mapping-token.js";
import {SequenceToken} from "./sequence-token.js";
import {StringToken} from "./string-token.js";
import {TemplateToken} from "./template-token.js";
describe("traverse", () => {
it("returns parent token and key", () => {
it("returns parent token, key, and ancestors", () => {
const workflow = parseWorkflow(
{
name: "wf.yaml",
@@ -18,19 +20,118 @@ describe("traverse", () => {
const traverser = TemplateToken.traverse(root);
// Root
expect(traverser.next()!.value).toEqual([undefined, root, undefined]);
const rootResult = traverser.next()!.value!;
expect(rootResult[0]).toBeUndefined();
expect(rootResult[1]).toBe(root);
expect(rootResult[2]).toBeUndefined();
expect(rootResult[3]).toEqual([]);
// On
const onResult = traverser.next().value!;
expect(onResult[0]).toBe(root);
expect(getValue(onResult[1])).toEqual("on");
expect(onResult[2]).toBeUndefined();
expect(onResult[3]).toEqual([root]);
// Push
const pushResult = traverser.next().value!;
expect(pushResult[0]).toBe(root);
expect(getValue(pushResult[1])).toEqual("push");
expect(getValue(pushResult[2])).toEqual("on");
expect(pushResult[3]).toEqual([root]);
});
it("returns ancestors for nested mappings", () => {
const workflow = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest`
},
nullTrace
);
const root = workflow.value!;
const results = Array.from(TemplateToken.traverse(root));
// Find the "ubuntu-latest" token
const ubuntuResult = results.find(r => getValue(r[1]) === "ubuntu-latest")!;
expect(ubuntuResult).toBeDefined();
// Ancestors should be: root -> jobs mapping -> build mapping
const ancestors = ubuntuResult[3];
expect(ancestors.length).toBe(3);
expect(ancestors[0]).toBe(root);
expect(ancestors[1]).toBeInstanceOf(MappingToken); // jobs mapping
expect(ancestors[2]).toBeInstanceOf(MappingToken); // build mapping
});
it("returns ancestors for sequences", () => {
const workflow = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hello`
},
nullTrace
);
const root = workflow.value!;
const results = Array.from(TemplateToken.traverse(root));
// Find the "echo hello" token
const echoResult = results.find(r => getValue(r[1]) === "echo hello")!;
expect(echoResult).toBeDefined();
// Ancestors should be: root -> jobs mapping -> build mapping -> steps sequence -> step mapping
const ancestors = echoResult[3];
expect(ancestors.length).toBe(5);
expect(ancestors[0]).toBe(root);
expect(ancestors[1]).toBeInstanceOf(MappingToken); // jobs mapping
expect(ancestors[2]).toBeInstanceOf(MappingToken); // build mapping
expect(ancestors[3]).toBeInstanceOf(SequenceToken); // steps sequence
expect(ancestors[4]).toBeInstanceOf(MappingToken); // step mapping
});
it("returns correct ancestors for matrix values", () => {
const workflow = parseWorkflow(
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node: [a, b]
steps:
- run: echo hi`
},
nullTrace
);
const root = workflow.value!;
const results = Array.from(TemplateToken.traverse(root));
// Find the "a" token (first matrix value)
const nodeValueResult = results.find(r => {
const token = r[1];
return token instanceof StringToken && token.value === "a";
})!;
expect(nodeValueResult).toBeDefined();
// Ancestors: root -> jobs mapping -> build mapping -> strategy mapping -> matrix mapping -> node sequence
const ancestors = nodeValueResult[3];
expect(ancestors.length).toBeGreaterThanOrEqual(5);
expect(ancestors[0]).toBe(root);
// Last ancestor should be the sequence containing [a, b]
expect(ancestors[ancestors.length - 1]).toBeInstanceOf(SequenceToken);
});
});
@@ -185,14 +185,23 @@ export abstract class TemplateToken {
/**
* Returns all tokens (depth first)
* @param value The object to travese
* @param value The object to traverse
* @param omitKeys Whether to omit mapping keys
* @yields A tuple of [parent, token, keyToken, ancestors] for each token in the tree
*/
public static *traverse(
value: TemplateToken,
omitKeys?: boolean
): Generator<[parent: TemplateToken | undefined, token: TemplateToken, keyToken: TemplateToken | undefined], void> {
yield [undefined, value, undefined];
): Generator<
[
parent: TemplateToken | undefined,
token: TemplateToken,
keyToken: TemplateToken | undefined,
ancestors: TemplateToken[]
],
void
> {
yield [undefined, value, undefined, []];
switch (value.templateTokenType) {
case TokenType.Sequence:
@@ -202,7 +211,7 @@ export abstract class TemplateToken {
while (state.parent) {
if (state.moveNext(omitKeys ?? false)) {
value = state.current as TemplateToken;
yield [state.parent?.current, value, state.currentKey];
yield [state.parent?.current, value, state.currentKey, state.getAncestors()];
switch (value.type) {
case TokenType.Sequence:
@@ -66,4 +66,19 @@ export class TraversalState {
throw new Error(`Unexpected token type '${this._token.templateTokenType}' when traversing state`);
}
}
/**
* Returns the ancestor tokens from root to the current token's parent container.
*/
public getAncestors(): TemplateToken[] {
const ancestors: TemplateToken[] = [];
let state: TraversalState | undefined = this.parent;
while (state) {
if (state.current) {
ancestors.unshift(state.current);
}
state = state.parent;
}
return ancestors;
}
}
@@ -152,11 +152,27 @@ export class YamlObjectReader implements ObjectReader {
return new BooleanToken(fileId, range, value, undefined);
case "string": {
let source: string | undefined;
let blockScalarHeader: string | undefined;
if (token.srcToken && "source" in token.srcToken) {
source = token.srcToken.source;
// Extract block scalar header (e.g., |-, |+, >-)
//
// CST node interfaces are supported and documented per yaml library maintainer:
// https://eemeli.org/yaml/#parser -> "For a complete description of CST node
// interfaces, please consult the cst.ts source."
// See also: https://github.com/eemeli/yaml/issues/643
if (token.srcToken.type === "block-scalar" && "props" in token.srcToken) {
const props = token.srcToken.props as Array<{type: string; source?: string}>;
const headerProp = props.find(p => p.type === "block-scalar-header");
if (headerProp?.source) {
blockScalarHeader = headerProp.source;
}
}
}
return new StringToken(fileId, range, value, undefined, source);
return new StringToken(fileId, range, value, undefined, source, blockScalarHeader);
}
default:
throw new Error(`Unexpected value type '${typeof value}' when reading object`);