Compare commits

...

60 Commits

Author SHA1 Message Date
eric sciple c85997ad0d Gate container image validation behind feature flag
Add containerImageValidation experimental feature flag that gates the
new container image validation behind an opt-in toggle. When the flag
is off (default), the legacy converter logic is used. When enabled,
the improved validation with expression handling runs.

The legacy code is duplicated to keep code paths fully isolated and
make the eventual cleanup diff minimal — just delete the legacy
functions and the flag guards.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-13 21:02:30 +00:00
eric sciple 671f92dbc6 Add validation for empty container image
Related PR:
- https://github.com/actions/runner/pull/4220

Relaxing schema non-empty-string for container/service image and moving to custom validation. This matches current production behavior which allows empty string at runtime, but not parse time.
2026-02-05 23:11:58 +00:00
eric sciple fb5c6e4f27 Add private repository access to step-uses description (#322)
Update the step-uses description to mention that actions can also be
used from private repositories when access is enabled via repository
settings.

Fixes #319
2026-01-30 09:23:48 -06:00
Allan Guigou f29f508cec Merge pull request #321 from actions/release/0.3.44
Release version 0.3.44
2026-01-29 15:36:01 -05:00
GitHub Actions d69c1fa0f3 Release extension version 0.3.44 2026-01-29 18:13:09 +00:00
Allan Guigou 191a7b6a00 Merge pull request #320 from actions/allanguigou/default-case
Remove experimental flag for `case` function
2026-01-29 13:10:33 -05:00
Allan Guigou 0410ab8302 Add featureFlags param with lint ignore 2026-01-29 17:24:35 +00:00
Allan Guigou 7ac83f43a6 Fix unused param 2026-01-29 16:51:18 +00:00
Allan Guigou ef457b29fa Remove unused feature flag param 2026-01-29 16:08:16 +00:00
Allan Guigou fea8440c1d Fix lint 2026-01-29 15:56:43 +00:00
Allan Guigou 3c0a5f79fc Remove experimental flag for case function 2026-01-29 14:34:51 +00:00
github-actions[bot] 448180bd7f Release extension version 0.3.43 (#318)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-27 08:57:45 -06:00
eric sciple d2f52a9043 Validate implicit if conditions in action.yml files (#317)
## Problem

In workflow YAML files, writing `if: foo == bar` shows an error because `foo` and `bar` are not valid contexts. However, the same invalid expression in an action.yml file showed no error.

## Solution

Add expression validation for implicit `if` conditions in action.yml files, matching the behavior of workflow YAML validation.

## What's new

1. **Pre-if/post-if validation** (node and docker actions)
   - `pre-if: foo == bar` now shows error for unknown context
   - `post-if: unknownFunc()` now shows error for unknown function

2. **Composite step `if` validation** (fix)
   - Errors from `convertToIfCondition` were being lost due to call ordering
   - Now captured correctly by calling conversion before retrieving errors

## Why the refactor?

The diff includes consolidating multiple validation loops into a single `validateAllTokens()` traversal. This matches the pattern used in workflow YAML validation (`additionalValidations`), making the code consistent between the two validation paths.
2026-01-27 08:37:42 -06:00
github-actions[bot] 46b216a6dc Release extension version 0.3.42 (#316)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-25 20:53:27 -06:00
eric sciple 0fe7798548 Support pre-if/post-if autocomplete and fix expression functions for action.yml (#314) 2026-01-25 20:47:30 -06:00
github-actions[bot] bdd72406c3 Release extension version 0.3.41 (#313)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-23 00:09:45 -06:00
eric sciple 33291f0f8d Add missing validation for action.yml (parity with workflow files) (#311)
* Add missing validation for action.yml (parity with workflow files)

- Add uses format validation for composite action steps
  - Validates owner/repo@ref format
  - Supports docker:// and ./ local references
  - Warns about shortened SHA refs (security concern)
  - Detects reusable workflow references in wrong context

- Add if literal text detection for composite action steps
  - Detects literal text outside ${{ }} that makes conditions always truthy
  - Works for both plain string and mixed expression formats
  - Uses shared hasFormatWithLiteralText() utility

- Add pre-if/post-if validation for node and docker actions
  - Errors on explicit ${{ }} syntax (runner only supports implicit expressions)
  - Literal text detection for implicit expressions
  - New runs-if schema type with proper context (runner, github, job, env, inputs, status functions)
  - Validates only in strict schema used by language services

- Add format() function validation for all expressions
  - Validates format string syntax in all expression contexts
  - Checks argument count matches placeholders

- Fix env and matrix context providers to return complete=false
  - Prevents false positive 'unknown context' errors
  - Matches behavior of other dynamic contexts (secrets, vars, etc.)

- Refactor validation utilities into utils/validate-uses.ts and utils/validate-if.ts
  - Shared between workflow and action validation
  - Consistent error messages and codes

* Add strategy and matrix contexts to runs-if definition

Based on runner source code analysis (actions/runner):
- ExecutionContext.InitializeJob() populates ExpressionValues from message.ContextData
- strategy and matrix are part of message.ContextData, available before any steps run
- StepsRunner evaluates all steps (pre, main, post) using the same code path

Did NOT add:
- steps: empty at pre-if time (no steps completed yet)
- hashFiles: workspace files don't exist at pre-step time
2026-01-23 00:02:02 -06:00
eric sciple 8511ae2e6d Allow empty string for container options (#312) 2026-01-22 15:21:11 -06:00
github-actions[bot] cd1078fb2f Release extension version 0.3.40 (#310)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-21 17:05:31 -06:00
eric sciple 96be7ce46c Clean up feature flag actionScaffoldingSnippets (#309) 2026-01-21 16:52:14 -06:00
eric sciple c2bf928e7b Add 'snippet' label detail to action scaffolding completions (#308) 2026-01-21 15:56:11 -06:00
eric sciple 74d69b24ab Fix scaffolding snippets to replace typed text instead of inserting (#307) 2026-01-21 15:41:25 -06:00
eric sciple 22aa458809 Add documentation links to action scaffolding snippets (#306) 2026-01-21 14:24:57 -06:00
github-actions[bot] f3f11d8658 Release extension version 0.3.39 (#305)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-19 15:19:33 -06:00
eric sciple 5359433879 Pass featureFlags to onCompletion in language server (#304)
* Pass featureFlags to onCompletion in language server

* Use import type for FeatureFlags in on-completion.ts
2026-01-19 15:11:32 -06:00
github-actions[bot] a8bfe74256 Release extension version 0.3.38 (#303)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-19 14:00:31 -06:00
eric sciple e2c5f1f74a Fix shell variable escaping in action.yml snippets (#302) 2026-01-19 13:55:42 -06:00
github-actions[bot] 2a203ec742 Release extension version 0.3.37 (#301)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-19 09:56:35 -06:00
eric sciple 92960e0093 Fix action snippet completions: sort order, indent, and $ escaping (#300) 2026-01-19 09:39:04 -06:00
Francesco Renzi 0fe31c6656 Setup CodeActions and add quickfix for missing inputs (#254)
* Setup CodeActions and add quickfix for missing inputs

* PR feedback

* Update languageservice/src/code-actions/quickfix/add-missing-inputs.ts

Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>

* Fix indentSize detection for code actions after rebase

- Add indentSize to MissingInputsDiagnosticData interface
- Pass indentSize parameter from validate.ts to validateActionReference
- Detect indentSize from workflow structure (jobs key to first child)
- Fall back to detecting from with: block children when available

* update typescript

* formatting

* linting

* Gate missing inputs quickfix behind feature flag

* Address PR review: rename files, move position calculation to quickfix

- Rename index.ts files to follow repo patterns:
  - code-actions/index.ts → code-actions/code-actions.ts
  - code-actions/quickfix/index.ts → quickfix/quickfix-providers.ts
- Move position calculation from validation to quickfix:
  - MissingInputsDiagnosticData now passes raw token ranges
  - Quickfix computes insertion position and indentation at code action time
  - detectIndentSize moved from validate.ts to validate-action-reference.ts

* wip

* Remove pointless comment

---------

Co-authored-by: Salman Chishti <salmanmkc@GitHub.com>
2026-01-14 15:58:20 +00:00
Allan Guigou 67dd4fbd61 Merge pull request #298 from actions/release/0.3.36
Release version 0.3.36
2026-01-14 10:26:27 -05:00
GitHub Actions 4a7e08774d Release extension version 0.3.36 2026-01-14 15:24:00 +00:00
Allan Guigou 9ec1c123a8 Merge pull request #294 from actions/allanguigou/case
Add support for case function
2026-01-14 10:13:17 -05:00
Allan Guigou aad3bcd291 Fix tests 2026-01-14 14:48:52 +00:00
Allan Guigou 248934d513 Fix formatting 2026-01-14 14:17:49 +00:00
Allan Guigou b605cb6582 Merge branch 'main' into allanguigou/case 2026-01-14 13:51:13 +00:00
Allan Guigou 05debf64b0 Add experimental flag for case function 2026-01-14 13:17:15 +00:00
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
Allan Guigou 228acc3cd9 Update case.ts 2026-01-13 09:53:34 -05:00
Allan Guigou 9f30846fde Merge branch 'main' into allanguigou/case 2026-01-13 09:46:34 -05: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
Allan Guigou 0ebe1262ee Add case to completion tests 2026-01-08 15:01:43 +00:00
Allan Guigou 94d7f7b124 Remove unncessary type conversion 2026-01-08 14:33:59 +00:00
Allan Guigou f439272f69 Update import paths to include file extensions 2026-01-08 09:21:12 -05:00
Allan Guigou 161574adac Merge branch 'main' into allanguigou/case 2026-01-08 09:12:05 -05: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
Allan Guigou 44900feff7 Add support for case function 2026-01-06 14:42:01 +00: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
github-actions[bot] c67c353245 Release extension version 0.3.32 (#281)
Co-authored-by: GitHub Actions <github-actions@github.com>
2026-01-02 14:47:49 -06:00
eric sciple c6d2036302 Remove filterText from escape hatch completions (#280)
Escape hatch completions like '(switch to list)' and '(switch to mapping)'
were being filtered out in VS Code because filterText was set to the key
name (e.g., 'runs-on'), which doesn't match the empty string at the cursor
position when completing a value.

Since escape hatches only appear when the value is empty anyway, there's no
need for filterText. Without it, VS Code uses the label for filtering,
which properly shows them when no text is typed.
2026-01-02 14:44:57 -06:00
100 changed files with 6908 additions and 402 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.31",
"version": "0.3.44",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
+5 -1
View File
@@ -2,6 +2,7 @@ import {DescriptionPair} from "./completion/descriptionDictionary.js";
import {Dictionary, isDictionary} from "./data/dictionary.js";
import {ExpressionData} from "./data/expressiondata.js";
import {Evaluator} from "./evaluator.js";
import {FeatureFlags} from "./features.js";
import {wellKnownFunctions} from "./funcs.js";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
import {Lexer, Token, TokenType} from "./lexer.js";
@@ -26,13 +27,16 @@ export type CompletionItem = {
* @param context Context available for the expression
* @param extensionFunctions List of functions available
* @param functions Optional map of functions to use during evaluation
* @param featureFlags Optional feature flags to control which features are enabled
* @returns Array of completion items
*/
export function complete(
input: string,
context: Dictionary,
extensionFunctions: FunctionInfo[],
functions?: Map<string, FunctionDefinition>
functions?: Map<string, FunctionDefinition>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
featureFlags?: FeatureFlags
): CompletionItem[] {
// Lex
const lexer = new Lexer(input);
+3
View File
@@ -12,6 +12,7 @@ export enum ErrorType {
ErrorExceededMaxLength,
ErrorTooFewParameters,
ErrorTooManyParameters,
ErrorEvenParameters,
ErrorUnrecognizedContext,
ErrorUnrecognizedFunction
}
@@ -42,6 +43,8 @@ function errorDescription(typ: ErrorType): string {
return "Too few parameters supplied";
case ErrorType.ErrorTooManyParameters:
return "Too many parameters supplied";
case ErrorType.ErrorEvenParameters:
return "Even number of parameters supplied, requires an odd number of parameters";
case ErrorType.ErrorUnrecognizedContext:
return "Unrecognized named-value";
case ErrorType.ErrorUnrecognizedFunction:
+57
View File
@@ -0,0 +1,57 @@
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"]);
});
});
});
+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 improved container image validation that handles
* expressions gracefully and validates empty/docker:// images.
* @default false
*/
containerImageValidation?: 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",
"containerImageValidation"
];
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));
}
}
+7
View File
@@ -1,4 +1,5 @@
import {ErrorType, ExpressionError} from "./errors.js";
import {caseFunc} from "./funcs/case.js";
import {contains} from "./funcs/contains.js";
import {endswith} from "./funcs/endswith.js";
import {format} from "./funcs/format.js";
@@ -16,6 +17,7 @@ export type ParseContext = {
};
export const wellKnownFunctions: {[name: string]: FunctionDefinition} = {
case: caseFunc,
contains: contains,
endswith: endswith,
format: format,
@@ -53,4 +55,9 @@ export function validateFunction(context: ParseContext, identifier: Token, argCo
if (argCount > f.maxArgs) {
throw new ExpressionError(ErrorType.ErrorTooManyParameters, identifier);
}
// case function requires an odd number of arguments
if (name === "case" && argCount % 2 === 0) {
throw new ExpressionError(ErrorType.ErrorEvenParameters, identifier);
}
}
+29
View File
@@ -0,0 +1,29 @@
import {ExpressionData, Kind} from "../data/index.js";
import {FunctionDefinition} from "./info.js";
export const caseFunc: FunctionDefinition = {
name: "case",
description:
"`case( pred1, val1, pred2, val2, ..., default )`\n\nEvaluates predicates in order and returns the value corresponding to the first predicate that evaluates to `true`. If no predicate matches, it returns the last argument as the default value.",
minArgs: 3,
maxArgs: Number.MAX_SAFE_INTEGER,
call: (...args: ExpressionData[]): ExpressionData => {
// Evaluate predicate-result pairs
for (let i = 0; i < args.length - 1; i += 2) {
const predicate = args[i];
// Predicate must be a boolean
if (predicate.kind !== Kind.Boolean) {
throw new Error("case predicate must evaluate to a boolean value");
}
// If predicate is true, return the corresponding result
if (predicate.value) {
return args[i + 1];
}
}
// No predicate matched, return default (last argument)
return args[args.length - 1];
}
};
+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";
+157
View File
@@ -0,0 +1,157 @@
{
"case": [
{
"expr": "case(true, 'first', 'default')",
"result": { "kind": "String", "value": "first" }
},
{
"expr": "case(false, 'first', 'default')",
"result": { "kind": "String", "value": "default" }
},
{
"expr": "case(true, 'first', false, 'second', 'default')",
"result": { "kind": "String", "value": "first" }
},
{
"expr": "case(false, 'first', true, 'second', 'default')",
"result": { "kind": "String", "value": "second" }
},
{
"expr": "case(false, 'first', false, 'second', 'default')",
"result": { "kind": "String", "value": "default" }
},
{
"expr": "case(1 == 1, 'equal', 'not equal')",
"result": { "kind": "String", "value": "equal" }
},
{
"expr": "case(1 == 2, 'equal', 'not equal')",
"result": { "kind": "String", "value": "not equal" }
},
{
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
"contexts": {
"github": {
"ref": "refs/heads/main",
"event_name": "push"
}
},
"result": { "kind": "String", "value": "main" }
},
{
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
"contexts": {
"github": {
"ref": "refs/heads/develop",
"event_name": "pull_request"
}
},
"result": { "kind": "String", "value": "pr" }
},
{
"expr": "case(github.ref == 'refs/heads/main', 'main', github.event_name == 'pull_request', 'pr', 'other')",
"contexts": {
"github": {
"ref": "refs/heads/develop",
"event_name": "push"
}
},
"result": { "kind": "String", "value": "other" }
},
{
"expr": "case(true, 123, 456)",
"result": { "kind": "Number", "value": 123 }
},
{
"expr": "case(false, 123, 456)",
"result": { "kind": "Number", "value": 456 }
},
{
"expr": "case(github.event == 'pull_request', 0, 1)",
"contexts": {
"github": {
"event": "pull_request"
}
},
"result": { "kind": "Number", "value": 0 }
},
{
"expr": "case(false, 0, 1)",
"result": { "kind": "Number", "value": 1 }
},
{
"expr": "case(true, false, true)",
"result": { "kind": "Boolean", "value": false }
},
{
"expr": "case(false, false, true)",
"result": { "kind": "Boolean", "value": true }
},
{
"expr": "case(true, '', 'default')",
"result": { "kind": "String", "value": "" }
},
{
"expr": "case(false, 'first', '')",
"result": { "kind": "String", "value": "" }
},
{
"expr": "case(true, fromJSON('[1,2,3]'), 'default')",
"result": { "kind": "Array", "value": [1, 2, 3] }
},
{
"expr": "case(true, fromJSON('{\"key\":\"value\"}'), 'default')",
"result": { "kind": "Object", "value": { "key": "value" } }
},
{
"expr": "case(false, 'first', false, 'second', false, 'third', false, 'fourth', 'default')",
"result": { "kind": "String", "value": "default" }
},
{
"expr": "case(false, 'first', false, 'second', true, 'third', false, 'fourth', 'default')",
"result": { "kind": "String", "value": "third" }
},
{
"expr": "case('not a boolean', 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(1, 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(null, 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(fromJSON('[]'), 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(fromJSON('{}'), 'first', 'default')",
"err": {
"kind": "evaluation",
"value": "case predicate must evaluate to a boolean value"
}
},
{
"expr": "case(true, 'first', false, 'second')",
"err": {
"kind": "parsing",
"value": "Even number of parameters supplied, requires an odd number of parameters: 'case'. Located at position 1 within expression: case(true, 'first', false, 'second')"
}
}
]
}
+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.31",
"version": "0.3.44",
"description": "Language server for GitHub Actions",
"license": "MIT",
"type": "module",
@@ -48,8 +48,8 @@
"actions-languageserver": "./bin/actions-languageserver"
},
"dependencies": {
"@actions/languageservice": "^0.3.31",
"@actions/workflow-parser": "^0.3.31",
"@actions/languageservice": "^0.3.44",
"@actions/workflow-parser": "^0.3.44",
"@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2",
+51 -16
View File
@@ -1,8 +1,18 @@
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
import {
documentLinks,
getCodeActions,
getInlayHints,
hover,
validate,
ValidationConfig
} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem,
Connection,
DocumentLink,
@@ -20,18 +30,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 +52,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 +76,8 @@ export function initConnection(connection: Connection) {
setLogLevel(options.logLevel);
}
featureFlags = new FeatureFlags(options.experimentalFeatures);
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Full,
@@ -75,7 +89,10 @@ export function initConnection(connection: Connection) {
documentLinkProvider: {
resolveProvider: false
},
inlayHintProvider: true
inlayHintProvider: true,
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
}
}
};
@@ -91,6 +108,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 +136,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);
@@ -131,7 +154,8 @@ export function initConnection(connection: Connection) {
getDocument(documents, textDocument),
client,
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
cache
cache,
featureFlags
)
);
});
@@ -167,6 +191,17 @@ export function initConnection(connection: Connection) {
});
});
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
const document = getDocument(documents, params.textDocument);
return getCodeActions({
uri: params.textDocument.uri,
documentContent: document.getText(),
diagnostics: params.context.diagnostics,
only: params.context.only,
featureFlags
});
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
+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 {
+10 -7
View File
@@ -1,13 +1,14 @@
import {complete} from "@actions/languageservice/complete";
import type {FeatureFlags} from "@actions/expressions";
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,
@@ -15,11 +16,13 @@ export async function onCompletion(
document: TextDocument,
client: Octokit | undefined,
repoContext: RepositoryContext | undefined,
cache: TTLCache
cache: TTLCache,
featureFlags?: FeatureFlags
): Promise<CompletionItem[]> {
return await complete(document, position, {
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
featureFlags,
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
return await connection.sendRequest(Requests.ReadFile, {path});
})
@@ -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.31",
"version": "0.3.44",
"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.31",
"@actions/workflow-parser": "^0.3.31",
"@actions/expressions": "^0.3.44",
"@actions/workflow-parser": "^0.3.44",
"vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8",
@@ -0,0 +1,55 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
import {CodeActionContext, CodeActionProvider} from "./types.js";
import {getQuickfixProviders} from "./quickfix/quickfix-providers.js";
export interface CodeActionParams {
uri: string;
documentContent: string;
diagnostics: Diagnostic[];
only?: string[];
featureFlags?: FeatureFlags;
}
export function getCodeActions(params: CodeActionParams): CodeAction[] {
const actions: CodeAction[] = [];
const context: CodeActionContext = {
uri: params.uri,
documentContent: params.documentContent,
featureFlags: params.featureFlags
};
// Build providers map based on feature flags
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
[CodeActionKind.QuickFix, getQuickfixProviders(params.featureFlags)]
// [CodeActionKind.Refactor, getRefactorProviders(params.featureFlags)],
// [CodeActionKind.Source, getSourceProviders(params.featureFlags)],
// etc
]);
// Filter to requested kinds, or use all if none specified
const requestedKinds = params.only;
const kindsToCheck = requestedKinds
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
: [...providersByKind.keys()];
for (const diagnostic of params.diagnostics) {
for (const kind of kindsToCheck) {
const providers = providersByKind.get(kind) ?? [];
for (const provider of providers) {
if (provider.diagnosticCodes.includes(diagnostic.code)) {
const action = provider.createCodeAction(context, diagnostic);
if (action) {
action.kind = kind;
action.diagnostics = [diagnostic];
actions.push(action);
}
}
}
}
}
return actions;
}
export type {CodeActionContext, CodeActionProvider} from "./types.js";
@@ -0,0 +1,245 @@
import {isMapping} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {CodeAction, Position, TextEdit} from "vscode-languageserver-types";
import {error} from "../../log.js";
import {findToken} from "../../utils/find-token.js";
import {getOrParseWorkflow} from "../../utils/workflow-cache.js";
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action-reference.js";
import {CodeActionContext, CodeActionProvider} from "../types.js";
/**
* Information extracted from a step token needed to generate edits
*/
interface StepInfo {
/** Column where step keys start (1-indexed), e.g., the column of "uses:" */
stepKeyColumn: number;
/** End line of the step (1-indexed) */
stepEndLine: number;
/** Detected indent size (spaces per level) */
indentSize: number;
/** Information about existing with: block, if present */
withInfo?: {
keyColumn: number;
keyEndLine: number;
valueEndLine: number;
hasChildren: boolean;
/** Column of first child input (1-indexed), for indentation detection */
firstChildColumn?: number;
};
}
export const addMissingInputsProvider: CodeActionProvider = {
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
createCodeAction(context: CodeActionContext, diagnostic): CodeAction | undefined {
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
if (!data) {
return undefined;
}
// Parse the document to get the step token
const stepInfo = getStepInfo(context, diagnostic.range.start);
if (!stepInfo) {
return undefined;
}
const edits = createInputEdits(data.missingInputs, stepInfo);
if (!edits || edits.length === 0) {
return undefined;
}
const inputNames = data.missingInputs.map(i => i.name).join(", ");
return {
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
edit: {
changes: {
[context.uri]: edits
}
}
};
}
};
/**
* Parse the document and extract step information needed for generating edits.
* Returns undefined if parsing fails or the step token cannot be found.
*/
function getStepInfo(context: CodeActionContext, diagnosticPosition: Position): StepInfo | undefined {
// Parse the document (uses cache if available from validation)
const file = {name: context.uri, content: context.documentContent};
const parseResult = getOrParseWorkflow(file, context.uri);
if (!parseResult.value) {
error("Failed to parse workflow for missing inputs quickfix");
return undefined;
}
// Find the token at the diagnostic position
const {path} = findToken(diagnosticPosition, parseResult.value);
// Walk up the path to find the step token (regular-step)
const stepToken = findStepInPath(path);
if (!stepToken) {
error("Could not find step token for missing inputs quickfix");
return undefined;
}
return extractStepInfo(stepToken);
}
/**
* Find the step token (regular-step) in the token path
*/
function findStepInPath(path: TemplateToken[]): MappingToken | undefined {
// Walk backwards through path to find the step
for (let i = path.length - 1; i >= 0; i--) {
if (path[i].definition?.key === "regular-step" && isMapping(path[i])) {
return path[i] as MappingToken;
}
}
return undefined;
}
/**
* Extract position and indentation info from a step token
*/
function extractStepInfo(stepToken: MappingToken): StepInfo | undefined {
if (!stepToken.range) {
return undefined;
}
// Get the column of the first key in the step
let stepKeyColumn = stepToken.range.start.column;
if (stepToken.count > 0) {
const firstEntry = stepToken.get(0);
if (firstEntry?.key.range) {
stepKeyColumn = firstEntry.key.range.start.column;
}
}
// Find the with: block if present
let withKey: ScalarToken | undefined;
let withToken: TemplateToken | undefined;
for (const {key, value} of stepToken) {
if (key.toString() === "with") {
withKey = key;
withToken = value;
break;
}
}
// Calculate indent size
let indentSize = 2; // Default
let withInfo: StepInfo["withInfo"];
if (withKey?.range && withToken?.range) {
// Has with: block - extract its info
const hasChildren = isMapping(withToken) && withToken.count > 0;
let firstChildColumn: number | undefined;
if (hasChildren) {
const firstChild = (withToken as MappingToken).get(0);
if (firstChild?.key.range) {
firstChildColumn = firstChild.key.range.start.column;
// Detect indent size from with: children
indentSize = firstChildColumn - withKey.range.start.column;
}
}
withInfo = {
keyColumn: withKey.range.start.column,
keyEndLine: withKey.range.end.line,
valueEndLine: withToken.range.end.line,
hasChildren,
firstChildColumn
};
} else {
// No with: block - detect indent size using heuristics
// Based on the step key column position, estimate indent size
// 2-space indent files typically have step keys at column 7
// 4-space indent files typically have step keys at column 15
const zeroIndexedCol = stepKeyColumn - 1;
if (zeroIndexedCol >= 10) {
indentSize = 4;
}
}
return {
stepKeyColumn,
stepEndLine: stepToken.range.end.line,
indentSize,
withInfo
};
}
/**
* Generate text edits to add missing inputs
*/
function createInputEdits(missingInputs: MissingInputsDiagnosticData["missingInputs"], stepInfo: StepInfo): TextEdit[] {
const formatInputLines = (indent: string) =>
missingInputs.map(input => {
const value = input.default ?? '""';
return `${indent}${input.name}: ${value}`;
});
if (stepInfo.withInfo) {
// `with:` exists - add inputs to existing block
const withIndent = stepInfo.withInfo.keyColumn - 1; // 0-indexed
const inputIndentSize = stepInfo.withInfo.firstChildColumn
? stepInfo.withInfo.firstChildColumn - stepInfo.withInfo.keyColumn
: stepInfo.indentSize;
const inputIndent = " ".repeat(withIndent + inputIndentSize);
const inputLines = formatInputLines(inputIndent);
// Calculate insert position
let insertLine: number;
if (stepInfo.withInfo.hasChildren) {
// Insert after the last child (at end of with: block)
// valueEndLine is 1-indexed, we want 0-indexed for Position
insertLine = stepInfo.withInfo.valueEndLine - 1;
} else {
// Empty with: block - insert on the next line after with:
// keyEndLine is 1-indexed, convert to 0-indexed and go to next line
insertLine = stepInfo.withInfo.keyEndLine;
}
const insertPosition: Position = {
line: insertLine,
character: 0
};
return [
{
range: {start: insertPosition, end: insertPosition},
newText: inputLines.map(line => line + "\n").join("")
}
];
} else {
// No `with:` key - add `with:` at the same level as other step keys
const withKeyIndent = stepInfo.stepKeyColumn - 1; // 0-indexed (columns are 1-based)
const withIndent = " ".repeat(withKeyIndent);
const inputIndent = " ".repeat(withKeyIndent + stepInfo.indentSize);
const inputLines = formatInputLines(inputIndent);
const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");
// Insert at end of step
// stepEndLine is 1-indexed, we want 0-indexed and insert before the line after
const insertPosition: Position = {
line: stepInfo.stepEndLine - 1,
character: 0
};
return [
{
range: {start: insertPosition, end: insertPosition},
newText
}
];
}
}
@@ -0,0 +1,13 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeActionProvider} from "../types.js";
import {addMissingInputsProvider} from "./add-missing-inputs.js";
export function getQuickfixProviders(featureFlags?: FeatureFlags): CodeActionProvider[] {
const providers: CodeActionProvider[] = [];
if (featureFlags?.isEnabled("missingInputsQuickfix")) {
providers.push(addMissingInputsProvider);
}
return providers;
}
@@ -0,0 +1,90 @@
import * as path from "path";
import {fileURLToPath} from "url";
import {loadTestCases, runTestCase} from "./runner.js";
import {ValidationConfig} from "../../validate.js";
import {ActionMetadata, ActionReference} from "../../action.js";
import {clearCache} from "../../utils/workflow-cache.js";
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Mock action metadata provider for tests
const validationConfig: ValidationConfig = {
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
const key = `${ref.owner}/${ref.name}@${ref.ref}`;
const metadata: Record<string, ActionMetadata> = {
"actions/cache@v1": {
name: "Cache",
description: "Cache dependencies",
inputs: {
path: {
description: "A list of files to cache",
required: true
},
key: {
description: "Cache key",
required: true
},
"restore-keys": {
description: "Restore keys",
required: false
}
}
},
"actions/setup-node@v3": {
name: "Setup Node",
description: "Setup Node.js",
inputs: {
"node-version": {
description: "Node version",
required: true,
default: "16"
}
}
}
};
return Promise.resolve(metadata[key]);
}
}
};
// Point to the source testdata directory
const testdataDir = path.join(__dirname, "testdata");
beforeEach(() => {
clearCache();
});
describe("code action golden tests", () => {
const testCases = loadTestCases(testdataDir);
if (testCases.length === 0) {
it.todo("no test cases found - add .yml files to testdata/");
return;
}
for (const testCase of testCases) {
it(testCase.name, async () => {
const result = await runTestCase(testCase, validationConfig);
if (!result.passed) {
let errorMessage = result.error || "Test failed";
if (result.expected !== undefined && result.actual !== undefined) {
errorMessage += "\n\n";
errorMessage += "=== EXPECTED (golden file) ===\n";
errorMessage += result.expected;
errorMessage += "\n\n";
errorMessage += "=== ACTUAL ===\n";
errorMessage += result.actual;
}
throw new Error(errorMessage);
}
});
}
});
@@ -0,0 +1,231 @@
import * as fs from "fs";
import * as path from "path";
import {TextEdit} from "vscode-languageserver-types";
import {TextDocument} from "vscode-languageserver-textdocument";
import {FeatureFlags} from "@actions/expressions";
import {validate, ValidationConfig} from "../../validate.js";
import {getCodeActions, CodeActionParams} from "../code-actions.js";
// Marker pattern: # want "diagnostic message" fix="code-action-name"
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
export interface TestCase {
name: string;
inputPath: string;
goldenPath: string;
input: string;
golden: string;
markers: Marker[];
}
export interface Marker {
line: number;
message: string;
fix?: string;
}
export interface TestResult {
name: string;
passed: boolean;
error?: string;
expected?: string;
actual?: string;
}
/**
* Parse markers from input file content
*/
export function parseMarkers(content: string): Marker[] {
const lines = content.split("\n");
const markers: Marker[] = [];
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(MARKER_PATTERN);
if (match) {
markers.push({
line: i,
message: match[1],
fix: match[2]
});
}
}
return markers;
}
/**
* Strip markers from content (for processing)
*/
export function stripMarkers(content: string): string {
return content
.split("\n")
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
.join("\n");
}
/**
* Load all test cases from a testdata directory
*/
export function loadTestCases(testdataDir: string): TestCase[] {
const testCases: TestCase[] = [];
function walkDir(dir: string) {
const entries = fs.readdirSync(dir, {withFileTypes: true});
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(".golden.yml")) {
const goldenPath = fullPath.replace(".yml", ".golden.yml");
if (fs.existsSync(goldenPath)) {
const input = fs.readFileSync(fullPath, "utf-8");
const golden = fs.readFileSync(goldenPath, "utf-8");
testCases.push({
name: path.relative(testdataDir, fullPath),
inputPath: fullPath,
goldenPath,
input,
golden,
markers: parseMarkers(input)
});
}
}
}
}
walkDir(testdataDir);
return testCases;
}
/**
* Apply text edits to a document
*/
export function applyEdits(content: string, edits: TextEdit[]): string {
// Sort edits in reverse order by position to apply from bottom to top
const sortedEdits = [...edits].sort((a, b) => {
if (b.range.start.line !== a.range.start.line) {
return b.range.start.line - a.range.start.line;
}
return b.range.start.character - a.range.start.character;
});
const lines = content.split("\n");
for (const edit of sortedEdits) {
const startLine = edit.range.start.line;
const startChar = edit.range.start.character;
const endLine = edit.range.end.line;
const endChar = edit.range.end.character;
const before = lines[startLine].slice(0, startChar);
const after = lines[endLine].slice(endChar);
const newLines = edit.newText.split("\n");
newLines[0] = before + newLines[0];
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
lines.splice(startLine, endLine - startLine + 1, ...newLines);
}
return lines.join("\n");
}
/**
* Run a single test case
*/
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
const strippedInput = stripMarkers(testCase.input);
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
// 1. Validate and get diagnostics
const diagnostics = await validate(document, validationConfig);
// 2. Verify all expected diagnostics are present
const missingDiagnostics: string[] = [];
for (const marker of testCase.markers) {
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!found) {
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
}
}
if (missingDiagnostics.length > 0) {
return {
name: testCase.name,
passed: false,
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
"\n "
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
};
}
// 3. Collect all edits from all matching code actions
const allEdits: TextEdit[] = [];
for (const marker of testCase.markers) {
if (!marker.fix) {
continue;
}
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!diagnostic) {
continue; // Already reported above
}
const params: CodeActionParams = {
uri: document.uri,
documentContent: strippedInput,
diagnostics: [diagnostic],
featureFlags: new FeatureFlags({all: true})
};
const actions = getCodeActions(params);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- marker.fix is checked at the start of the loop
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
if (!matchingAction) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${
actions.map(a => a.title).join(", ") || "(none)"
}`
};
}
if (!matchingAction.edit?.changes) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" has no edits`
};
}
const edits = matchingAction.edit.changes[document.uri] || [];
allEdits.push(...edits);
}
// 4. Apply all edits and compare to golden file
const actualOutput = applyEdits(strippedInput, allEdits);
const expectedOutput = testCase.golden;
if (actualOutput.trim() !== expectedOutput.trim()) {
return {
name: testCase.name,
passed: false,
error: "Output does not match golden file",
expected: expectedOutput,
actual: actualOutput
};
}
return {
name: testCase.name,
passed: true
};
}
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,7 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -0,0 +1,10 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: ${{ runner.os }}-
path: ""
key: ""
@@ -0,0 +1,8 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
restore-keys: ${{ runner.os }}-
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,6 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,6 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
+23
View File
@@ -0,0 +1,23 @@
import {FeatureFlags} from "@actions/expressions";
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
export interface CodeActionContext {
uri: string;
documentContent: string;
featureFlags?: FeatureFlags;
}
/**
* A provider that can produce a code action for a given diagnostic
*/
export interface CodeActionProvider {
/**
* The diagnostic codes this provider handles
*/
diagnosticCodes: (string | number | undefined)[];
/**
* Create a code action for the diagnostic, if applicable
*/
createCodeAction(context: CodeActionContext, diagnostic: Diagnostic): CodeAction | undefined;
}
+426
View File
@@ -134,6 +134,49 @@ runs:
expect(labels).toContain("arch");
expect(labels).toContain("temp");
});
it("completes if expression value for composite run step", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- if: |
run: echo "hello"
shell: bash`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show expression-related completions (status functions and contexts)
expect(labels).toContain("always");
expect(labels).toContain("success");
expect(labels).toContain("failure");
expect(labels).toContain("cancelled");
expect(labels).toContain("runner");
expect(labels).toContain("github");
expect(labels).toContain("inputs");
expect(labels).toContain("steps");
});
it("completes if expression value for composite uses step", async () => {
const [doc, position] = createActionDocument(`name: My Action
description: Test action
runs:
using: composite
steps:
- if: |
uses: actions/checkout@v4`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show expression-related completions
expect(labels).toContain("always");
expect(labels).toContain("success");
expect(labels).toContain("failure");
expect(labels).toContain("cancelled");
expect(labels).toContain("runner");
expect(labels).toContain("github");
});
});
describe("top-level completions", () => {
@@ -184,6 +227,186 @@ 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 node24 actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node24
|`);
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("completes pre-if expression value for node actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node24
main: index.js
pre: setup.js
pre-if: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show expression-related completions (context functions and namespaces)
expect(labels).toContain("always");
expect(labels).toContain("success");
expect(labels).toContain("failure");
expect(labels).toContain("cancelled");
expect(labels).toContain("runner");
expect(labels).toContain("github");
expect(labels).toContain("inputs");
expect(labels).toContain("hashFiles");
});
it("completes post-if expression value for node actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: node24
main: index.js
post: cleanup.js
post-if: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show expression-related completions
expect(labels).toContain("always");
expect(labels).toContain("runner");
expect(labels).toContain("hashFiles");
});
it("completes pre-if expression value for docker actions", async () => {
const [doc, position] = createActionDocument(`name: Test
description: Test
runs:
using: docker
image: docker://alpine
pre-entrypoint: setup.sh
pre-if: |`);
const completions = await complete(doc, position);
const labels = completions.map(c => c.label);
// Should show expression-related completions
expect(labels).toContain("always");
expect(labels).toContain("runner");
expect(labels).toContain("github");
expect(labels).toContain("hashFiles");
});
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 after snippets
expect(usingCompletion?.sortText).toBe("9_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 +474,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 +515,175 @@ 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);
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);
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);
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);
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);
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);
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);
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);
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);
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);
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);
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("replaces typed text when selecting scaffolding snippet", async () => {
// User typed "compo" and then triggered completion
const [doc, position] = createActionDocument(`compo|`);
const completions = await complete(doc, position);
const compositeSnippet = completions.find(c => c.label === "Composite Action");
expect(compositeSnippet).toBeDefined();
// The textEdit should replace "compo", not insert after it
const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}};
expect(textEdit.range.start.character).toBe(0); // Start of "compo"
expect(textEdit.range.end.character).toBe(5); // End of "compo"
});
it("handles empty file with no typed text", async () => {
const [doc, position] = createActionDocument(`|`);
const completions = await complete(doc, position);
const compositeSnippet = completions.find(c => c.label === "Composite Action");
const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}};
// Zero-length range is fine when there's nothing to replace
expect(textEdit.range.start.character).toBe(0);
expect(textEdit.range.end.character).toBe(0);
});
});
});
+480
View File
@@ -0,0 +1,480 @@
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, Range, 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\\\`);
`;
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: "9_using"}; // Sort after snippets (0_, 1_, 2_)
}
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,
replaceRange?: Range
): 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\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
ACTION_SNIPPET_NODEJS_USING,
position,
"0_nodejs",
replaceRange
),
createSnippetCompletion(
"Composite Action",
"Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
ACTION_SNIPPET_COMPOSITE_USING,
position,
"1_composite",
replaceRange
),
createSnippetCompletion(
"Docker Action",
"Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
ACTION_SNIPPET_DOCKER_USING,
position,
"2_docker",
replaceRange
)
];
}
// 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\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
ACTION_SNIPPET_NODEJS_RUNS,
position,
"1_nodejs",
replaceRange
),
createSnippetCompletion(
"Composite Action",
"Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
ACTION_SNIPPET_COMPOSITE_RUNS,
position,
"2_composite",
replaceRange
),
createSnippetCompletion(
"Docker Action",
"Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
ACTION_SNIPPET_DOCKER_RUNS,
position,
"3_docker",
replaceRange
)
];
}
// Show "_FULL" variants (complete scaffold)
return [
createSnippetCompletion(
"Node.js Action",
"Scaffold a complete Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
ACTION_SNIPPET_NODEJS_FULL,
position,
"1_nodejs",
replaceRange
),
createSnippetCompletion(
"Composite Action",
"Scaffold a complete composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
ACTION_SNIPPET_COMPOSITE_FULL,
position,
"2_composite",
replaceRange
),
createSnippetCompletion(
"Docker Action",
"Scaffold a complete Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
ACTION_SNIPPET_DOCKER_FULL,
position,
"3_docker",
replaceRange
)
];
}
/**
* Creates a snippet completion item.
*/
function createSnippetCompletion(
label: string,
description: string,
snippetText: string,
position: Position,
sortText: string,
replaceRange?: Range
): CompletionItem {
// Use replace if we have a range, otherwise insert at position
const textEdit = replaceRange ? TextEdit.replace(replaceRange, snippetText) : TextEdit.insert(position, snippetText);
return {
label,
labelDetails: {description: "snippet"},
kind: CompletionItemKind.Snippet,
documentation: {
kind: "markdown",
value: description
},
insertTextFormat: InsertTextFormat.Snippet,
sortText,
textEdit
};
}
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {data, DescriptionDictionary} from "@actions/expressions";
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
import {CompletionItem, CompletionItemKind, MarkupContent} from "vscode-languageserver-types";
import {complete, getExpressionInput} from "./complete.js";
import {ContextProviderConfig} from "./context-providers/config.js";
import {registerLogger} from "./log.js";
@@ -68,12 +68,15 @@ describe("expressions", () => {
describe("top-level auto-complete", () => {
it("single region", async () => {
const input = "run-name: ${{ | }}";
const result = await complete(...getPositionFromCursor(input));
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -108,12 +111,15 @@ describe("expressions", () => {
it("single region with existing input", async () => {
const input = "run-name: ${{ g| }}";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -126,12 +132,15 @@ describe("expressions", () => {
it("single region with existing condition", async () => {
const input = "run-name: ${{ g| == 'test' }}";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -144,12 +153,15 @@ describe("expressions", () => {
it("multiple regions with partial function", async () => {
const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -162,12 +174,15 @@ describe("expressions", () => {
it("multiple regions - first region", async () => {
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -180,12 +195,15 @@ describe("expressions", () => {
it("multiple regions", async () => {
const input = "run-name: test-${{ github }}-${{ | }}";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
"github",
"inputs",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -395,6 +413,36 @@ jobs:
expect(result.map(x => x.label)).toEqual(["event"]);
});
it("includes both contexts and extension functions", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo
if: |`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const labels = result.map(x => x.label);
// Context namespaces should be present
expect(labels).toContain("github");
expect(labels).toContain("runner");
expect(labels).toContain("env");
expect(labels).toContain("steps");
// Extension functions should be present (from schema context array)
expect(labels).toContain("hashFiles");
expect(labels).toContain("always");
expect(labels).toContain("success");
expect(labels).toContain("failure");
expect(labels).toContain("cancelled");
// Built-in functions should be present
expect(labels).toContain("toJson");
expect(labels).toContain("fromJson");
expect(labels).toContain("contains");
});
});
});
@@ -1126,7 +1174,9 @@ jobs:
run: echo hi
`;
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
const result = await complete(...getPositionFromCursor(input), {
contextProviderConfig
});
expect(result.map(x => x.label)).toEqual([
"env",
"github",
@@ -1139,6 +1189,7 @@ jobs:
"steps",
"strategy",
"vars",
"case",
"contains",
"endsWith",
"format",
@@ -1250,6 +1301,7 @@ jobs:
expect(hashFiles).toBeDefined();
expect(hashFiles!.kind).toBe(CompletionItemKind.Function);
expect(hashFiles!.insertText).toBe("hashFiles()");
expect((hashFiles!.documentation as MarkupContent)?.value).toContain("Returns a single hash for the set of files");
// Not a function
const github = result.find(x => x.label === "github");
+40 -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 - "
});
});
@@ -876,4 +895,17 @@ jobs:
expect(result.some(x => x.label === "macos-latest")).toBe(true);
});
});
describe("expression completions", () => {
it("includes case function", async () => {
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
// Expression completions starting with 'c': case, contains
const labels = result.map(x => x.label);
expect(labels).toContain("case");
expect(labels).toContain("contains");
});
});
});
+76 -22
View File
@@ -1,8 +1,10 @@
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 {FunctionInfo} from "@actions/expressions/funcs/info";
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
@@ -16,8 +18,10 @@ 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 {getFunctionDescription} from "./context-providers/descriptions.js";
import {ActionContext, getActionContext} from "./context/action-context.js";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
import {validatorFunctions} from "./expression-validation/functions.js";
@@ -54,6 +58,7 @@ export type CompletionConfig = {
valueProviderConfig?: ValueProviderConfig;
contextProviderConfig?: ContextProviderConfig;
fileProvider?: FileProvider;
featureFlags?: FeatureFlags;
};
export async function complete(
@@ -119,25 +124,31 @@ export async function complete(
}
// Expression completions
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
if (token && (isBasicExpression(token) || isPotentiallyExpression(token, isAction))) {
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions: extensionFunctions} = splitAllowedContext(allowedContext);
const context = isAction
? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion)
? getActionExpressionContext(namedContexts, config?.contextProviderConfig, actionContext, Mode.Completion)
: await getWorkflowExpressionContext(
allowedContext,
namedContexts,
config?.contextProviderConfig,
workflowContext,
Mode.Completion
);
return getExpressionCompletionItems(token, context, newPos);
// Populate function descriptions for completion display
for (const func of extensionFunctions) {
func.description = getFunctionDescription(func.name);
}
return getExpressionCompletionItems(token, context, extensionFunctions, newPos, config?.featureFlags);
}
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
const indentString = " ".repeat(indentation.tabSize);
// YAML key/value completions
const values = await getValues(
let values = await getValues(
token,
keyToken,
parent,
@@ -147,6 +158,11 @@ 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);
@@ -178,8 +194,14 @@ export async function complete(
}
}
// Get action scaffolding snippets if applicable
let actionSnippets: CompletionItem[] = [];
if (isAction) {
actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position, replaceRange);
}
// 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 +214,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 +230,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 +420,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}
};
@@ -399,11 +441,16 @@ function getEscapeHatchCompletions(
results.push({
label: "(switch to list)",
sortText: "zzz_switch_1",
filterText: keyName, // Allow filtering by key name
textEdit: {
range: editRange,
newText: `${keyName}:\n${indentation}- `
}
range: cursorRange,
newText: `\n${indentation}- `
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
@@ -411,11 +458,16 @@ function getEscapeHatchCompletions(
results.push({
label: "(switch to mapping)",
sortText: "zzz_switch_2",
filterText: keyName, // Allow filtering by key name
textEdit: {
range: editRange,
newText: `${keyName}:\n${indentation}`
}
range: cursorRange,
newText: `\n${indentation}`
},
additionalTextEdits: [
{
range: keyToCursorRange,
newText: `${keyName}:`
}
]
});
}
@@ -478,7 +530,9 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT
function getExpressionCompletionItems(
token: TemplateToken,
context: DescriptionDictionary,
pos: Position
extensionFunctions: FunctionInfo[],
pos: Position,
featureFlags?: FeatureFlags
): CompletionItem[] {
if (!token.range) {
return [];
@@ -497,8 +551,8 @@ function getExpressionCompletionItems(
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
try {
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
mapExpressionCompletionItem(item, currentInput[cursorOffset])
return completeExpression(expressionInput, context, extensionFunctions, validatorFunctions, featureFlags).map(
item => mapExpressionCompletionItem(item, currentInput[cursorOffset])
);
} catch (e) {
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
@@ -198,9 +198,13 @@ function getDefaultActionContext(
case "runner":
return getRunnerContext();
case "env":
// Actions can access env but we don't have runtime values
return new DescriptionDictionary();
case "env": {
// Actions can access env but we don't know what env vars the calling workflow defines
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
const envContext = new DescriptionDictionary();
envContext.complete = false;
return envContext;
}
case "job": {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
@@ -218,9 +222,13 @@ function getDefaultActionContext(
case "strategy":
return getStrategyContext();
case "matrix":
// Actions can access matrix context at runtime
return new DescriptionDictionary();
case "matrix": {
// Actions can access matrix context at runtime but we don't know the calling workflow's matrix
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
const matrixContext = new DescriptionDictionary();
matrixContext.complete = false;
return matrixContext;
}
}
return undefined;
+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 () => {
+2 -3
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 () => {
@@ -196,7 +195,7 @@ jobs:
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "step-uses", undefined));
expect(result).not.toBeUndefined();
expect(result?.contents).toEqual(
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image."
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, a [private repository with access enabled](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository), or in a published Docker container image."
);
});
});
+14 -1
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";
@@ -69,7 +71,7 @@ export async function hover(document: TextDocument, position: Position, config?:
// Early exit if there's nothing to provide hover for
const hoverToken = token || keyToken;
const isExpressionHover =
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token));
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token, isAction));
if (!isExpressionHover && !hoverToken?.definition) {
return null;
}
@@ -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
+2 -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";
@@ -6,3 +6,4 @@ export {getInlayHints} from "./inlay-hints.js";
export {Logger, LogLevel, registerLogger, setLogLevel} from "./log.js";
export {validate, ValidationConfig, ActionsMetadataProvider} from "./validate.js";
export {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
export {getCodeActions, CodeActionParams} from "./code-actions/code-actions.js";
@@ -0,0 +1,170 @@
import {isPotentiallyExpression} from "./expression-detection.js";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
import {Definition} from "@actions/workflow-parser/templates/schema/definition";
// Helper to create a mock TemplateToken with the properties we need to test
function createMockToken(options: {value?: string; definitionKey?: string; isString?: boolean}): TemplateToken {
const {value = "", definitionKey, isString = true} = options;
const mockDefinition = definitionKey ? ({key: definitionKey} as Definition) : undefined;
return {
value: isString ? value : undefined,
definition: mockDefinition,
templateTokenType: isString ? TokenType.String : TokenType.Mapping,
// Required by isString type guard (isLiteral checks isLiteral property)
isLiteral: isString,
isScalar: isString
} as unknown as TemplateToken;
}
describe("isPotentiallyExpression", () => {
describe("expression markers", () => {
it("returns true when token value contains ${{", () => {
const token = createMockToken({value: "${{ github.actor }}"});
expect(isPotentiallyExpression(token, false)).toBe(true);
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns true when token value contains embedded ${{", () => {
const token = createMockToken({value: "Hello ${{ github.actor }}!"});
expect(isPotentiallyExpression(token, false)).toBe(true);
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns false when token value does not contain ${{", () => {
const token = createMockToken({value: "plain text"});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
it("returns false for non-string tokens without expression marker", () => {
const token = createMockToken({isString: false});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
});
describe("workflow schema if-conditions", () => {
it("returns true for job-if definition in workflow", () => {
const token = createMockToken({value: "success()", definitionKey: "job-if"});
expect(isPotentiallyExpression(token, false)).toBe(true);
});
it("returns false for job-if definition in action (not valid in action schema)", () => {
const token = createMockToken({value: "success()", definitionKey: "job-if"});
expect(isPotentiallyExpression(token, true)).toBe(false);
});
it("returns true for step-if definition in workflow", () => {
const token = createMockToken({value: "failure()", definitionKey: "step-if"});
expect(isPotentiallyExpression(token, false)).toBe(true);
});
it("returns true for snapshot-if definition in workflow", () => {
const token = createMockToken({value: "always()", definitionKey: "snapshot-if"});
expect(isPotentiallyExpression(token, false)).toBe(true);
});
it("returns false for snapshot-if definition in action (not valid in action schema)", () => {
const token = createMockToken({value: "always()", definitionKey: "snapshot-if"});
expect(isPotentiallyExpression(token, true)).toBe(false);
});
});
describe("action schema if-conditions", () => {
describe("composite action step if (run and uses)", () => {
it("returns true for step-if definition in action", () => {
const token = createMockToken({value: "success()", definitionKey: "step-if"});
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns true for step-if with run step condition", () => {
// Composite action run step: if condition
const token = createMockToken({value: "github.event_name == 'push'", definitionKey: "step-if"});
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns true for step-if with uses step condition", () => {
// Composite action uses step: if condition
const token = createMockToken({value: "runner.os == 'Linux'", definitionKey: "step-if"});
expect(isPotentiallyExpression(token, true)).toBe(true);
});
});
describe("pre-if and post-if (node/docker actions)", () => {
it("returns true for runs-if definition in action (pre-if)", () => {
const token = createMockToken({value: "runner.os == 'Linux'", definitionKey: "runs-if"});
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns true for runs-if definition in action (post-if)", () => {
const token = createMockToken({value: "always()", definitionKey: "runs-if"});
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns false for runs-if definition in workflow (not valid in workflow schema)", () => {
const token = createMockToken({value: "always()", definitionKey: "runs-if"});
expect(isPotentiallyExpression(token, false)).toBe(false);
});
});
});
describe("mixed scenarios", () => {
it("returns true when expression marker present even if definition is not if-related", () => {
const token = createMockToken({value: "${{ github.actor }}", definitionKey: "some-other-definition"});
expect(isPotentiallyExpression(token, false)).toBe(true);
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns true when both expression marker and if definition present", () => {
const token = createMockToken({value: "${{ success() }}", definitionKey: "step-if"});
expect(isPotentiallyExpression(token, false)).toBe(true);
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("returns false for plain text with non-if definition", () => {
const token = createMockToken({value: "plain text", definitionKey: "string"});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
it("returns false when token has no definition and no expression marker", () => {
const token = createMockToken({value: "plain text"});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
});
describe("edge cases", () => {
it("handles empty string value", () => {
const token = createMockToken({value: ""});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
it("handles expression marker as if-condition value", () => {
const token = createMockToken({value: "${{ always() }}", definitionKey: "job-if"});
expect(isPotentiallyExpression(token, false)).toBe(true);
// For action, job-if is not valid, but ${{ is present
expect(isPotentiallyExpression(token, true)).toBe(true);
});
it("handles partial expression marker", () => {
const token = createMockToken({value: "${incomplete"});
expect(isPotentiallyExpression(token, false)).toBe(false);
expect(isPotentiallyExpression(token, true)).toBe(false);
});
it("handles ${{ at different positions", () => {
const startToken = createMockToken({value: "${{ foo }} bar"});
const middleToken = createMockToken({value: "bar ${{ foo }} baz"});
const endToken = createMockToken({value: "bar ${{ foo }}"});
expect(isPotentiallyExpression(startToken, false)).toBe(true);
expect(isPotentiallyExpression(middleToken, false)).toBe(true);
expect(isPotentiallyExpression(endToken, false)).toBe(true);
});
});
});
@@ -2,10 +2,36 @@ import {isString} from "@actions/workflow-parser";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
export function isPotentiallyExpression(token: TemplateToken): boolean {
const containsExpression = isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0;
// If conditions are always expressions (job-if, step-if, snapshot-if)
const definitionKey = token.definition?.key;
const isIfCondition = definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if";
return containsExpression || isIfCondition;
/**
* Workflow schema if-condition definition keys.
* - job-if: job level if condition
* - step-if: step level if condition
* - snapshot-if: snapshot if condition
*/
const WORKFLOW_IF_DEFINITIONS = new Set(["job-if", "step-if", "snapshot-if"]);
/**
* Action schema if-condition definition keys.
* - step-if: composite action step if condition (run-step and uses-step)
* - runs-if: pre-if and post-if at the runs level (node/docker actions)
*/
const ACTION_IF_DEFINITIONS = new Set(["step-if", "runs-if"]);
export function isPotentiallyExpression(token: TemplateToken, isAction: boolean): boolean {
// Check if token contains expression syntax
if (isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0) {
return true;
}
// Check if token is an if-condition (always treated as expressions)
if (!token.definition?.key) {
return false;
}
// Definition keys differ between workflow and action schemas
if (isAction) {
return ACTION_IF_DEFINITIONS.has(token.definition.key);
} else {
return WORKFLOW_IF_DEFINITIONS.has(token.definition.key);
}
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Shared validation utilities for `if` condition literal text detection.
* Used by both workflow and action validation.
*/
import {data} from "@actions/expressions";
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
/**
* Checks if a format function contains literal text in its format string.
* This indicates user confusion about how expressions work.
*
* Example: format('push == {0}', github.event_name)
* The literal text "push == " will always evaluate to truthy.
*
* @param expr The expression to check
* @returns true if the expression is a format() call with literal text
*/
export function hasFormatWithLiteralText(expr: Expr): boolean {
// If this is a logical AND expression (from ensureStatusFunction wrapping)
// check the right side for the format call
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
return hasFormatWithLiteralText(expr.args[1]);
}
if (!(expr instanceof FunctionCall)) {
return false;
}
// Check if this is a format function
if (expr.functionName.lexeme.toLowerCase() !== "format") {
return false;
}
// Check if the first argument is a string literal
if (expr.args.length < 1) {
return false;
}
const firstArg = expr.args[0];
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
return false;
}
// Get the format string and trim whitespace
const formatString = firstArg.literal.coerceString();
const trimmed = formatString.trim();
// Check if there's literal text (non-replacement tokens) after trimming
let inToken = false;
for (let i = 0; i < trimmed.length; i++) {
if (!inToken && trimmed[i] === "{") {
inToken = true;
} else if (inToken && trimmed[i] === "}") {
inToken = false;
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
// OK - this is a replacement token like {0}, {1}, etc.
} else {
// Found literal text
return true;
}
}
return false;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Shared validation utilities for step `uses` field format.
* Used by both workflow and action validation.
*/
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {mapRange} from "./range.js";
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
const SHORT_SHA_DOCS_URL =
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
/**
* Checks if a ref looks like a short SHA and adds a warning if so.
* Returns true if a warning was added.
*/
export function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
if (SHORT_SHA_PATTERN.test(ref)) {
diagnostics.push({
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
severity: DiagnosticSeverity.Warning,
range: mapRange(token.range),
code: "short-sha-ref",
codeDescription: {
href: SHORT_SHA_DOCS_URL
}
});
return true;
}
return false;
}
/**
* Validates the format of a step's `uses` field.
*
* Valid formats:
* - docker://image:tag
* - ./local/path
* - .\local\path (Windows)
* - {owner}/{repo}@{ref}
* - {owner}/{repo}/{path}@{ref}
*/
export function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
const uses = token.value;
// Empty uses value
if (!uses) {
diagnostics.push({
message: "'uses' value in action cannot be blank",
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
return;
}
// Docker image reference - always valid format
if (uses.startsWith("docker://")) {
return;
}
// Local action path - always valid format
if (uses.startsWith("./") || uses.startsWith(".\\")) {
return;
}
// Remote action: must be {owner}/{repo}[/path]@{ref}
const atSegments = uses.split("@");
// Must have exactly one @
if (atSegments.length !== 2) {
addStepUsesFormatError(diagnostics, token);
return;
}
const [repoPath, gitRef] = atSegments;
// Ref cannot be empty
if (!gitRef) {
addStepUsesFormatError(diagnostics, token);
return;
}
// Split by / or \ to get path segments
const pathSegments = repoPath.split(/[\\/]/);
// Must have at least owner and repo (both non-empty)
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
addStepUsesFormatError(diagnostics, token);
return;
}
// Check if this is a reusable workflow reference (should be at job level, not step)
// Path would be like: owner/repo/.github/workflows/file.yml
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
diagnostics.push({
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
return;
}
// Warn if ref looks like a short SHA
warnIfShortSha(diagnostics, token, gitRef);
}
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
diagnostics.push({
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
}
@@ -249,7 +249,21 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
}
]
}
}
]);
});
@@ -294,7 +308,25 @@ jobs:
line: 7
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
]
}
}
]);
});
@@ -323,7 +355,25 @@ jobs:
line: 6
}
},
severity: DiagnosticSeverity.Error
severity: DiagnosticSeverity.Error,
code: "missing-required-inputs",
data: {
action: {
name: "cache",
owner: "actions",
ref: "v1"
},
missingInputs: [
{
default: undefined,
name: "path"
},
{
default: undefined,
name: "key"
}
]
}
}
]);
});
@@ -4,10 +4,22 @@ import {Step} from "@actions/workflow-parser/model/workflow-template";
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
import {parseActionReference} from "./action.js";
import {ActionReference, parseActionReference} from "./action.js";
import {mapRange} from "./utils/range.js";
import {ValidationConfig} from "./validate.js";
export const DiagnosticCode = {
MissingRequiredInputs: "missing-required-inputs"
} as const;
export interface MissingInputsDiagnosticData {
action: ActionReference;
missingInputs: Array<{
name: string;
default?: string;
}>;
}
/**
* Validates action references in workflow steps, checking for valid inputs and required inputs.
*/
@@ -94,10 +106,22 @@ export async function validateActionReference(
missingRequiredInputs.length === 1
? `Missing required input \`${missingRequiredInputs[0][0]}\``
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
// Build minimal diagnostic data - position calculation happens in the quickfix
const diagnosticData: MissingInputsDiagnosticData = {
action,
missingInputs: missingRequiredInputs.map(([name, input]) => ({
name,
default: input.default
}))
};
diagnostics.push({
severity: DiagnosticSeverity.Error,
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
message: message
range: mapRange((withKey || stepToken).range),
message: message,
code: DiagnosticCode.MissingRequiredInputs,
data: diagnosticData
});
}
}
+915
View File
@@ -347,4 +347,919 @@ 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);
});
});
describe("composite step uses format validation", () => {
it("validates valid uses format with version", async () => {
const doc = createActionDocument(`
name: My Action
description: Uses another action
runs:
using: composite
steps:
- uses: actions/checkout@v4
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
});
it("validates docker:// uses format", async () => {
const doc = createActionDocument(`
name: My Action
description: Uses docker image
runs:
using: composite
steps:
- uses: docker://alpine:3.14
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
});
it("validates local ./ uses format", async () => {
const doc = createActionDocument(`
name: My Action
description: Uses local action
runs:
using: composite
steps:
- uses: ./local-action
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
});
it("errors on missing @ref", async () => {
const doc = createActionDocument(`
name: My Action
description: Missing version
runs:
using: composite
steps:
- uses: actions/checkout
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(true);
expect(diagnostics.some(d => d.message.includes("Expected format"))).toBe(true);
});
it("errors on invalid format", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid format
runs:
using: composite
steps:
- uses: invalid-format
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(true);
});
it("warns on short SHA", async () => {
const doc = createActionDocument(`
name: My Action
description: Short SHA
runs:
using: composite
steps:
- uses: actions/checkout@a1b2c3d
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "short-sha-ref")).toBe(true);
expect(diagnostics.some(d => d.message.includes("shortened commit SHA"))).toBe(true);
});
it("allows full SHA", async () => {
const doc = createActionDocument(`
name: My Action
description: Full SHA
runs:
using: composite
steps:
- uses: actions/checkout@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "short-sha-ref")).toBe(false);
});
it("errors on reusable workflow in step uses", async () => {
const doc = createActionDocument(`
name: My Action
description: Wrong workflow reference
runs:
using: composite
steps:
- uses: owner/repo/.github/workflows/build.yml@main
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Reusable workflows should be referenced"))).toBe(true);
});
});
describe("composite step if literal text validation", () => {
it("errors when literal text mixed with embedded expression", async () => {
const doc = createActionDocument(`
name: My Action
description: Literal text in if
runs:
using: composite
steps:
- if: push == \${{ github.event_name }}
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
expect(diagnostics.some(d => d.message.includes("literal text outside replacement tokens"))).toBe(true);
});
it("allows valid expression in if", async () => {
const doc = createActionDocument(`
name: My Action
description: Valid if expression
runs:
using: composite
steps:
- if: \${{ github.event_name == 'push' }}
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
});
it("allows if without expression markers (auto-wrapped)", async () => {
const doc = createActionDocument(`
name: My Action
description: If without markers
runs:
using: composite
steps:
- if: github.event_name == 'push'
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
});
it("allows success() function", async () => {
const doc = createActionDocument(`
name: My Action
description: Success function
runs:
using: composite
steps:
- if: success()
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
});
it("errors on format with literal text in if", async () => {
const doc = createActionDocument(`
name: My Action
description: Format with literal text
runs:
using: composite
steps:
- if: \${{ format('event is {0}', github.event_name) }}
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
});
it("allows format with only replacement tokens", async () => {
const doc = createActionDocument(`
name: My Action
description: Format with only tokens
runs:
using: composite
steps:
- if: \${{ format('{0}', github.event_name) }}
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
});
it("validates if in uses-step", async () => {
const doc = createActionDocument(`
name: My Action
description: If in uses step
runs:
using: composite
steps:
- if: push == \${{ github.event_name }}
uses: actions/checkout@v4
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
});
});
describe("pre-if and post-if validation", () => {
it("errors on explicit expression with literal text in pre-if for node action", async () => {
const doc = createActionDocument(`
name: My Action
description: Literal text in pre-if
runs:
using: node20
main: index.js
pre: setup.js
pre-if: push == \${{ github.event_name }}
`);
const diagnostics = await validate(doc);
// Explicit ${{ }} syntax is not allowed for pre-if, so we get that error
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
});
it("errors on explicit expression with literal text in post-if for node action", async () => {
const doc = createActionDocument(`
name: My Action
description: Literal text in post-if
runs:
using: node20
main: index.js
post: cleanup.js
post-if: event == \${{ github.event_name }}
`);
const diagnostics = await validate(doc);
// Explicit ${{ }} syntax is not allowed for post-if, so we get that error
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
});
it("errors on explicit expression with literal text in pre-if for docker action", async () => {
const doc = createActionDocument(`
name: My Action
description: Literal text in pre-if
runs:
using: docker
image: Dockerfile
pre-entrypoint: /setup.sh
pre-if: push == \${{ github.event_name }}
`);
const diagnostics = await validate(doc);
// Explicit ${{ }} syntax is not allowed for pre-if, so we get that error
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
});
it("errors on explicit expression with literal text in post-if for docker action", async () => {
const doc = createActionDocument(`
name: My Action
description: Literal text in post-if
runs:
using: docker
image: Dockerfile
post-entrypoint: /cleanup.sh
post-if: event == \${{ github.event_name }}
`);
const diagnostics = await validate(doc);
// Explicit ${{ }} syntax is not allowed for post-if, so we get that error
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
});
it("allows valid expression in pre-if for node action", async () => {
const doc = createActionDocument(`
name: My Action
description: Valid pre-if
runs:
using: node20
main: index.js
pre: setup.js
pre-if: success()
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
});
it("allows valid expression in post-if for node action", async () => {
const doc = createActionDocument(`
name: My Action
description: Valid post-if
runs:
using: node20
main: index.js
post: cleanup.js
post-if: always()
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
});
it("errors on explicit expression syntax in pre-if", async () => {
const doc = createActionDocument(`
name: My Action
description: Explicit expression in pre-if
runs:
using: node20
main: index.js
pre: setup.js
pre-if: \${{ runner.os == 'Windows' }}
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
expect(diagnostics.some(d => d.message.includes("pre-if"))).toBe(true);
});
it("errors on explicit expression syntax in post-if", async () => {
const doc = createActionDocument(`
name: My Action
description: Explicit expression in post-if
runs:
using: node20
main: index.js
post: cleanup.js
post-if: \${{ always() }}
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
expect(diagnostics.some(d => d.message.includes("post-if"))).toBe(true);
});
it("allows expression with failure() in post-if", async () => {
const doc = createActionDocument(`
name: My Action
description: Valid post-if
runs:
using: node20
main: index.js
post: cleanup.js
post-if: failure()
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
});
it("allows expression with cancelled() in post-if", async () => {
const doc = createActionDocument(`
name: My Action
description: Valid post-if
runs:
using: node20
main: index.js
post: cleanup.js
post-if: cancelled()
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
});
});
describe("format string validation", () => {
it("errors on format() with too few arguments in composite step if", async () => {
const doc = createActionDocument(`
name: My Action
description: Format mismatch
runs:
using: composite
steps:
- if: format('{0} {1}', 'only-one')
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
});
it("errors on invalid format string in composite step if", async () => {
const doc = createActionDocument(`
name: My Action
description: Invalid format
runs:
using: composite
steps:
- if: format('{', 'arg')
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(true);
});
it("errors on format() with too few arguments in pre-if", async () => {
const doc = createActionDocument(`
name: My Action
description: Format mismatch in pre-if
runs:
using: node20
main: index.js
pre: setup.js
pre-if: format('{0} {1}', 'only-one')
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
});
it("errors on format() with too few arguments in post-if", async () => {
const doc = createActionDocument(`
name: My Action
description: Format mismatch in post-if
runs:
using: node20
main: index.js
post: cleanup.js
post-if: format('{0} {1} {2}', 'a', 'b')
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
});
it("allows valid format() call in composite step if", async () => {
const doc = createActionDocument(`
name: My Action
description: Valid format
runs:
using: composite
steps:
- if: format('{0} {1}', 'a', 'b') == 'a b'
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(false);
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(false);
});
it("allows valid format() call in pre-if", async () => {
const doc = createActionDocument(`
name: My Action
description: Valid format in pre-if
runs:
using: node20
main: index.js
pre: setup.js
pre-if: format('{0}', runner.os) == 'Linux'
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(false);
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(false);
});
it("errors on format() with too few arguments in run expression", async () => {
const doc = createActionDocument(`
name: My Action
description: Format mismatch in run
runs:
using: composite
steps:
- run: echo \${{ format('{0} {1}', 'only-one') }}
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
});
it("errors on format() with too few arguments in input default", async () => {
const doc = createActionDocument(`
name: My Action
description: Format mismatch in input default
inputs:
greeting:
description: Greeting message
default: \${{ format('{0} {1}', 'hello') }}
runs:
using: node20
main: index.js
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
});
});
describe("if condition context validation", () => {
it("warns on unknown context in composite step if", async () => {
const doc = createActionDocument(`
name: My Action
description: Unknown context in if
runs:
using: composite
steps:
- if: foo == bar
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
});
it("warns on unknown context in pre-if for node action", async () => {
const doc = createActionDocument(`
name: My Action
description: Unknown context in pre-if
runs:
using: node20
main: index.js
pre: setup.js
pre-if: foo == bar
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
});
it("warns on unknown context in post-if for node action", async () => {
const doc = createActionDocument(`
name: My Action
description: Unknown context in post-if
runs:
using: node20
main: index.js
post: cleanup.js
post-if: foo == bar
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
});
it("warns on unknown context in pre-if for docker action", async () => {
const doc = createActionDocument(`
name: My Action
description: Unknown context in pre-if
runs:
using: docker
image: Dockerfile
pre-entrypoint: /setup.sh
pre-if: foo == bar
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
});
it("warns on unknown context in post-if for docker action", async () => {
const doc = createActionDocument(`
name: My Action
description: Unknown context in post-if
runs:
using: docker
image: Dockerfile
post-entrypoint: /cleanup.sh
post-if: foo == bar
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
});
it("allows valid contexts in composite step if", async () => {
const doc = createActionDocument(`
name: My Action
description: Valid context in if
runs:
using: composite
steps:
- if: github.event_name == 'push'
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
});
it("allows valid contexts in pre-if", async () => {
const doc = createActionDocument(`
name: My Action
description: Valid context in pre-if
runs:
using: node20
main: index.js
pre: setup.js
pre-if: runner.os == 'Linux'
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
});
it("allows valid contexts in post-if", async () => {
const doc = createActionDocument(`
name: My Action
description: Valid context in post-if
runs:
using: node20
main: index.js
post: cleanup.js
post-if: runner.os == 'Linux'
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
});
it("allows hashFiles function in composite step if", async () => {
const doc = createActionDocument(`
name: My Action
description: hashFiles in if
runs:
using: composite
steps:
- if: hashFiles('**/package-lock.json') != ''
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
});
it("allows success, failure, always, cancelled functions in composite step if", async () => {
const doc = createActionDocument(`
name: My Action
description: Status functions in if
runs:
using: composite
steps:
- if: success() && !cancelled()
run: echo success
shell: bash
- if: failure()
run: echo failure
shell: bash
- if: always()
run: echo always
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
});
it("allows hashFiles function in pre-if", async () => {
const doc = createActionDocument(`
name: My Action
description: hashFiles in pre-if
runs:
using: node20
main: index.js
pre: setup.js
pre-if: hashFiles('**/package-lock.json') != ''
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
});
it("allows status functions in post-if", async () => {
const doc = createActionDocument(`
name: My Action
description: Status functions in post-if
runs:
using: node20
main: index.js
post: cleanup.js
post-if: always() || failure()
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
});
it("errors on unknown function in composite step if", async () => {
const doc = createActionDocument(`
name: My Action
description: Unknown function in if
runs:
using: composite
steps:
- if: unknownFunc()
run: echo hi
shell: bash
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
});
it("errors on unknown function in pre-if for node action", async () => {
const doc = createActionDocument(`
name: My Action
description: Unknown function in pre-if
runs:
using: node20
main: index.js
pre: setup.js
pre-if: unknownFunc()
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
});
it("errors on unknown function in post-if for node action", async () => {
const doc = createActionDocument(`
name: My Action
description: Unknown function in post-if
runs:
using: node20
main: index.js
post: cleanup.js
post-if: unknownFunc()
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
});
it("errors on unknown function in pre-if for docker action", async () => {
const doc = createActionDocument(`
name: My Action
description: Unknown function in pre-if
runs:
using: docker
image: Dockerfile
pre-entrypoint: /setup.sh
pre-if: unknownFunc()
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
});
it("errors on unknown function in post-if for docker action", async () => {
const doc = createActionDocument(`
name: My Action
description: Unknown function in post-if
runs:
using: docker
image: Dockerfile
post-entrypoint: /cleanup.sh
post-if: unknownFunc()
`);
const diagnostics = await validate(doc);
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
});
});
});
+387 -9
View File
@@ -2,20 +2,58 @@
* Validation for action.yml / action.yaml manifest files
*/
import {isMapping} from "@actions/workflow-parser";
import {Lexer, Parser} from "@actions/expressions";
import {Expr} from "@actions/expressions/ast";
import {isMapping, isString} from "@actions/workflow-parser";
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {ActionTemplate} from "@actions/workflow-parser/actions/action-template";
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
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";
import {error} from "./log.js";
import {mapRange} from "./utils/range.js";
import {hasFormatWithLiteralText} from "./utils/validate-if.js";
import {validateStepUsesFormat} from "./utils/validate-uses.js";
import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
import {validateActionReference} from "./validate-action-reference.js";
import {validateFormatCalls} from "./validate-format-string.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 +76,24 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
return [];
}
// Map parser errors to diagnostics
for (const err of result.context.errors.getErrors()) {
// Convert the action template (this may add validation errors for pre-if/post-if)
let template: ActionTemplate | undefined;
if (result.value) {
template = getOrConvertActionTemplate(result.context, result.value, textDocument.uri, {
errorPolicy: ErrorPolicy.TryConversion
});
}
// Get schema and conversion errors (must be after conversion to include conversion 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
@@ -58,13 +112,9 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
}
// Validate composite action steps if we have a parsed result
if (result.value) {
const template = getOrConvertActionTemplate(result.context, result.value, textDocument.uri, {
errorPolicy: ErrorPolicy.TryConversion
});
if (result.value && template) {
// Only composite actions have steps to validate
if (template?.runs?.using === "composite") {
if (template.runs?.using === "composite") {
const steps = template.runs.steps ?? [];
// Find the steps sequence token from the raw parsed result
@@ -79,9 +129,17 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
if (isActionStep(step) && isMapping(stepToken)) {
await validateActionReference(diagnostics, stepToken, step, config);
}
// Validate step uses format
if (isMapping(stepToken)) {
validateStepUsesField(diagnostics, stepToken);
}
}
}
}
// Single traversal for all expression validation (like workflow's additionalValidations)
validateAllTokens(diagnostics, result.value);
}
} catch (e) {
error(`Unhandled error while validating action file: ${(e as Error).message}`);
@@ -90,6 +148,196 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
return diagnostics;
}
/**
* Validates the `uses` field format in a composite action step.
*/
function validateStepUsesField(diagnostics: Diagnostic[], stepToken: MappingToken): void {
for (let i = 0; i < stepToken.count; i++) {
const {key, value} = stepToken.get(i);
const keyStr = isString(key) ? key.value.toLowerCase() : "";
if (keyStr === "uses" && isString(value)) {
validateStepUsesFormat(diagnostics, value);
}
}
}
/**
* Single traversal validation for all tokens in the action template.
* This follows the same pattern as workflow validation's additionalValidations:
* - For BasicExpressionToken: validate format() calls
* - For StringToken on if conditions: validate literal text detection and format() calls
* - For pre-if/post-if with explicit ${{ }}: report error (not supported by runner)
*
* Context validation (unknown named values) is handled by workflow-parser during conversion.
*/
function validateAllTokens(diagnostics: Diagnostic[], root: TemplateToken): void {
for (const [parent, token] of TemplateToken.traverse(root)) {
const definitionKey = token.definition?.key;
// Validate all BasicExpressionToken instances for format() calls
if (token instanceof BasicExpressionToken && token.range) {
// Check for literal text in if conditions (format with literal text)
if (definitionKey === "step-if") {
validateIfLiteralText(diagnostics, token);
}
// Validate format() calls for all expressions
for (const expression of token.originalExpressions || [token]) {
validateExpressionFormatCalls(diagnostics, expression);
}
// Check for explicit ${{ }} in pre-if/post-if (not supported by runner)
if (definitionKey === "runs-if" && parent instanceof MappingToken) {
// Resolve the key name (pre-if or post-if) from parent mapping
let keyName: string | undefined;
for (let i = 0; i < parent.count; i++) {
const {key, value} = parent.get(i);
if (value === token) {
keyName = key.toString().toLowerCase();
break;
}
}
if (keyName) {
diagnostics.push({
message: `Explicit expression syntax \${{ }} is not supported for '${keyName}'. Remove the \${{ }} markers and use the expression directly.`,
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: "explicit-expression-not-allowed"
});
}
}
}
// Handle implicit if conditions (StringToken without ${{ }})
// These allow expression syntax without the markers
if (isString(token) && token.range) {
if (definitionKey === "step-if" || definitionKey === "runs-if") {
validateImplicitIfCondition(diagnostics, token);
}
}
}
}
const LITERAL_TEXT_IN_CONDITION_MESSAGE =
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?";
const LITERAL_TEXT_IN_CONDITION_CODE = "expression-literal-text-in-condition";
/**
* Validates an implicit if condition (StringToken without ${{ }}).
* Checks for literal text detection and validates format() calls.
*/
function validateImplicitIfCondition(diagnostics: Diagnostic[], token: StringToken): void {
const condition = token.value.trim();
if (!condition) {
return;
}
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
// Ensure the condition has a status function, wrapping if needed
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
try {
const l = new Lexer(finalCondition);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
// Check for literal text in the expression (format with literal text)
if (hasFormatWithLiteralText(expr)) {
diagnostics.push({
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: LITERAL_TEXT_IN_CONDITION_CODE
});
}
// Validate format() function calls
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
} catch {
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
}
}
/**
* Validates a BasicExpressionToken for literal text in if conditions.
*/
function validateIfLiteralText(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
try {
const l = new Lexer(token.expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
if (hasFormatWithLiteralText(expr)) {
diagnostics.push({
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
range: mapRange(token.range),
severity: DiagnosticSeverity.Error,
code: LITERAL_TEXT_IN_CONDITION_CODE
});
}
} catch {
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
}
}
/**
* Validates format() function calls in an expression token.
*/
function validateExpressionFormatCalls(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
const allowedContext = token.definitionInfo?.allowedContext || [];
const {namedContexts, functions} = splitAllowedContext(allowedContext);
try {
const l = new Lexer(token.expression);
const lr = l.lex();
const p = new Parser(lr.tokens, namedContexts, functions);
const expr = p.parse();
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
} catch {
// Ignore parse errors - they'll be caught by schema validation
}
}
/**
* Helper to validate format() function calls and add diagnostics.
*/
function validateFormatCallsAndAddDiagnostics(
diagnostics: Diagnostic[],
expr: Expr,
range: TokenRange | undefined
): void {
const formatErrors = validateFormatCalls(expr);
for (const formatError of formatErrors) {
if (formatError.type === "invalid-syntax") {
diagnostics.push({
message: `Invalid format string: ${formatError.message}`,
range: mapRange(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(range),
severity: DiagnosticSeverity.Error,
code: "format-arg-count-mismatch"
});
}
}
}
/**
* Find the steps sequence token from the raw action template.
* Traverses the token tree looking for the "composite-steps" definition.
@@ -102,3 +350,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([]);
});
});
});
@@ -160,6 +160,21 @@ jobs:
})
);
});
it("errors on unknown context in plain string if condition", async () => {
const input = `
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- if: foo == bar
run: echo hi
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
});
});
describe("snapshot-if", () => {
@@ -211,4 +226,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"
})
);
});
});
});
+128 -175
View File
@@ -1,5 +1,5 @@
import {Lexer, Parser, data} from "@actions/expressions";
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
import {Expr} from "@actions/expressions/ast";
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
@@ -24,9 +24,12 @@ import {error} from "./log.js";
import {isActionDocument} from "./utils/document-type.js";
import {findToken} from "./utils/find-token.js";
import {mapRange} from "./utils/range.js";
import {hasFormatWithLiteralText} from "./utils/validate-if.js";
import {validateStepUsesFormat, warnIfShortSha} from "./utils/validate-uses.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 +41,7 @@ export type ValidationConfig = {
contextProviderConfig?: ContextProviderConfig;
actionsMetadataProvider?: ActionsMetadataProvider;
fileProvider?: FileProvider;
featureFlags?: FeatureFlags;
};
export type ActionsMetadataProvider = {
@@ -84,7 +88,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 +112,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 +133,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 +158,9 @@ async function additionalValidations(
finalCondition,
token.definitionInfo,
undefined,
token.source
token.source,
undefined,
token.blockScalarHeader
);
await validateExpression(
@@ -275,116 +287,6 @@ function validateCronExpression(diagnostics: Diagnostic[], token: StringToken):
}
}
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
const SHORT_SHA_DOCS_URL =
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
/**
* Checks if a ref looks like a short SHA and adds a warning if so.
* Returns true if a warning was added.
*/
function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
if (SHORT_SHA_PATTERN.test(ref)) {
diagnostics.push({
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
severity: DiagnosticSeverity.Warning,
range: mapRange(token.range),
code: "short-sha-ref",
codeDescription: {
href: SHORT_SHA_DOCS_URL
}
});
return true;
}
return false;
}
/**
* Validates the format of a step's `uses` field.
*
* Valid formats:
* - docker://image:tag
* - ./local/path
* - .\local\path (Windows)
* - {owner}/{repo}@{ref}
* - {owner}/{repo}/{path}@{ref}
*/
function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
const uses = token.value;
// Empty uses value
if (!uses) {
diagnostics.push({
message: "`uses' value in action cannot be blank",
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
return;
}
// Docker image reference - always valid format
if (uses.startsWith("docker://")) {
return;
}
// Local action path - always valid format
if (uses.startsWith("./") || uses.startsWith(".\\")) {
return;
}
// Remote action: must be {owner}/{repo}[/path]@{ref}
const atSegments = uses.split("@");
// Must have exactly one @
if (atSegments.length !== 2) {
addStepUsesFormatError(diagnostics, token);
return;
}
const [repoPath, gitRef] = atSegments;
// Ref cannot be empty
if (!gitRef) {
addStepUsesFormatError(diagnostics, token);
return;
}
// Split by / or \ to get path segments
const pathSegments = repoPath.split(/[\\/]/);
// Must have at least owner and repo (both non-empty)
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
addStepUsesFormatError(diagnostics, token);
return;
}
// Check if this is a reusable workflow reference (should be at job level, not step)
// Path would be like: owner/repo/.github/workflows/file.yml
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
diagnostics.push({
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
return;
}
// Warn if ref looks like a short SHA
warnIfShortSha(diagnostics, token, gitRef);
}
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
diagnostics.push({
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
severity: DiagnosticSeverity.Error,
range: mapRange(token.range),
code: "invalid-uses-format"
});
}
/**
* Validates the format of a job's `uses` field (reusable workflow reference).
*
@@ -629,64 +531,6 @@ function getProviderContext(
return getWorkflowContext(documentUri, template, path);
}
/**
* Checks if a format function contains literal text in its format string.
* This indicates user confusion about how expressions work.
*
* Example: format('push == {0}', github.event_name)
* The literal text "push == " will always evaluate to truthy.
*
* @param expr The expression to check
* @returns true if the expression is a format() call with literal text
*/
function hasFormatWithLiteralText(expr: Expr): boolean {
// If this is a logical AND expression (from ensureStatusFunction wrapping)
// check the right side for the format call
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
return hasFormatWithLiteralText(expr.args[1]);
}
if (!(expr instanceof FunctionCall)) {
return false;
}
// Check if this is a format function
if (expr.functionName.lexeme.toLowerCase() !== "format") {
return false;
}
// Check if the first argument is a string literal
if (expr.args.length < 1) {
return false;
}
const firstArg = expr.args[0];
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
return false;
}
// Get the format string and trim whitespace
const formatString = firstArg.literal.coerceString();
const trimmed = formatString.trim();
// Check if there's literal text (non-replacement tokens) after trimming
let inToken = false;
for (let i = 0; i < trimmed.length; i++) {
if (!inToken && trimmed[i] === "{") {
inToken = true;
} else if (inToken && trimmed[i] === "}") {
inToken = false;
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
// OK - this is a replacement token like {0}, {1}, etc.
} else {
// Found literal text
return true;
}
}
return false;
}
async function validateExpression(
diagnostics: Diagnostic[],
token: BasicExpressionToken,
@@ -735,6 +579,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 +688,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"
});
}
@@ -295,7 +295,7 @@ jobs:
`;
const result = await validate(createDocument("wf.yaml", input));
expect(result).toContainEqual({
message: "`uses' value in action cannot be blank",
message: "'uses' value in action cannot be blank",
severity: DiagnosticSeverity.Error,
range: {
start: {line: 5, character: 12},
@@ -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.31"
"version": "0.3.44"
}
+9 -9
View File
@@ -136,7 +136,7 @@
},
"expressions": {
"name": "@actions/expressions",
"version": "0.3.31",
"version": "0.3.44",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.0.3",
@@ -396,11 +396,11 @@
},
"languageserver": {
"name": "@actions/languageserver",
"version": "0.3.31",
"version": "0.3.44",
"license": "MIT",
"dependencies": {
"@actions/languageservice": "^0.3.31",
"@actions/workflow-parser": "^0.3.31",
"@actions/languageservice": "^0.3.44",
"@actions/workflow-parser": "^0.3.44",
"@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.31",
"version": "0.3.44",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.31",
"@actions/workflow-parser": "^0.3.31",
"@actions/expressions": "^0.3.44",
"@actions/workflow-parser": "^0.3.44",
"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.31",
"version": "0.3.44",
"license": "MIT",
"dependencies": {
"@actions/expressions": "^0.3.31",
"@actions/expressions": "^0.3.44",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@actions/workflow-parser",
"version": "0.3.31",
"version": "0.3.44",
"license": "MIT",
"type": "module",
"source": "./src/index.ts",
@@ -48,7 +48,7 @@
"watch": "tsc --build tsconfig.build.json --watch"
},
"dependencies": {
"@actions/expressions": "^0.3.31",
"@actions/expressions": "^0.3.44",
"cronstrue": "^2.21.0",
"yaml": "^2.0.0-8"
},
+23 -4
View File
@@ -137,6 +137,24 @@
],
"string": {}
},
"runs-if": {
"description": "Condition to control when this action's pre or post script runs.",
"context": [
"runner",
"github",
"job",
"strategy",
"matrix",
"env",
"inputs",
"always(0,0)",
"success(0,0)",
"failure(0,0)",
"cancelled(0,0)",
"hashFiles(1,255)"
],
"string": {}
},
"runs": {
"one-of": [
"container-runs",
@@ -242,7 +260,7 @@
"description": "Allows you to run a script before the entrypoint action begins.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-entrypoint)"
},
"pre-if": {
"type": "non-empty-string",
"type": "runs-if",
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
},
"post-entrypoint": {
@@ -250,7 +268,7 @@
"description": "Allows you to run a cleanup script once the runs.entrypoint action has completed.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-entrypoint)"
},
"post-if": {
"type": "non-empty-string",
"type": "runs-if",
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
}
}
@@ -267,6 +285,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": {
@@ -274,7 +293,7 @@
"description": "Allows you to run a script at the start of a job, before the main: action begins. You can use pre: to run prerequisite setup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre)"
},
"pre-if": {
"type": "non-empty-string",
"type": "runs-if",
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
},
"post": {
@@ -282,7 +301,7 @@
"description": "Allows you to run a script at the end of a job, once the main: action has completed. You can use post: to run cleanup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost)"
},
"post-if": {
"type": "non-empty-string",
"type": "runs-if",
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
}
}
@@ -317,4 +317,53 @@ runs:
}
}
});
it("reports error for invalid context in pre-if", () => {
const content = `
name: Node Action
description: A node action
runs:
using: node20
main: dist/index.js
pre: dist/setup.js
pre-if: foo == bar`;
const result = parseAction({name: "action.yml", content}, nullTrace);
expect(result.value).toBeDefined();
if (!result.value) return;
// Should have no errors before conversion
expect(result.context.errors.count).toBe(0);
// Convert the template - this should add the validation error
convertActionTemplate(result.context, result.value);
// Should have an error now about invalid context
expect(result.context.errors.count).toBeGreaterThan(0);
const errors = result.context.errors.getErrors();
expect(errors.some(e => e.rawMessage.includes("foo"))).toBe(true);
});
it("accepts valid context in pre-if", () => {
const content = `
name: Node Action
description: A node action
runs:
using: node20
main: dist/index.js
pre: dist/setup.js
pre-if: runner.os == 'Linux'`;
const result = parseAction({name: "action.yml", content}, nullTrace);
expect(result.value).toBeDefined();
if (!result.value) return;
const template = convertActionTemplate(result.context, result.value);
// Should have no errors
expect(result.context.errors.count).toBe(0);
if (template.runs.using === "node20") {
expect(template.runs.preIf).toBe("runner.os == 'Linux'");
}
});
});
@@ -9,7 +9,7 @@ import {TemplateContext} from "../templates/template-context.js";
import {isBoolean, isMapping, isScalar, isSequence, isString} from "../templates/tokens/type-guards.js";
import {ErrorPolicy} from "../model/convert.js";
import {Step} from "../model/workflow-template.js";
import {convertToIfCondition} from "../model/converter/if-condition.js";
import {convertToIfCondition, validateRunsIfCondition} from "../model/converter/if-condition.js";
/**
* Represents a parsed and converted action.yml file
@@ -310,7 +310,7 @@ function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns
case "pre-if":
if (isString(item.value)) {
preIf = item.value.value;
preIf = validateRunsIfCondition(context, item.value, item.value.value);
}
break;
@@ -322,7 +322,7 @@ function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns
case "post-if":
if (isString(item.value)) {
postIf = item.value.value;
postIf = validateRunsIfCondition(context, item.value, item.value.value);
}
break;
+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("|-");
});
});
});
+16 -2
View File
@@ -1,3 +1,4 @@
import {FeatureFlags} from "@actions/expressions";
import {TemplateContext} from "../templates/template-context.js";
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
import {FileProvider} from "../workflows/file-provider.js";
@@ -37,9 +38,15 @@ export type WorkflowTemplateConverterOptions = {
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
*/
errorPolicy?: ErrorPolicy;
/**
* Feature flags for experimental features.
* When not provided, all experimental features are disabled.
*/
featureFlags?: FeatureFlags;
};
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
const defaultOptions: Omit<Required<WorkflowTemplateConverterOptions>, "featureFlags"> = {
maxReusableWorkflowDepth: 4,
fetchReusableWorkflowDepth: 0,
errorPolicy: ErrorPolicy.ReturnErrorsOnly
@@ -54,6 +61,11 @@ export async function convertWorkflowTemplate(
const result = {} as WorkflowTemplate;
const opts = getOptionsWithDefaults(options);
// Store feature flags in context for converter functions
if (options.featureFlags) {
context.state["featureFlags"] = options.featureFlags;
}
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
result.errors = context.errors.getErrors().map(x => ({
Message: x.message
@@ -132,7 +144,9 @@ export async function convertWorkflowTemplate(
return result;
}
function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Required<WorkflowTemplateConverterOptions> {
function getOptionsWithDefaults(
options: WorkflowTemplateConverterOptions
): Omit<Required<WorkflowTemplateConverterOptions>, "featureFlags"> {
return {
maxReusableWorkflowDepth:
options.maxReusableWorkflowDepth !== undefined
@@ -0,0 +1,318 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {nullTrace} from "../../test-utils/null-trace.js";
import {parseWorkflow} from "../../workflows/workflow-parser.js";
import {convertWorkflowTemplate, ErrorPolicy} from "../convert.js";
// Minimal FeatureFlags-compatible object for tests
const featureFlags = {isEnabled: (f: string) => f === "containerImageValidation"};
async function getErrors(content: string): Promise<string[]> {
const result = parseWorkflow({name: "wf.yaml", content}, nullTrace);
result.context.state["featureFlags"] = featureFlags;
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
errorPolicy: ErrorPolicy.TryConversion
});
return (template.errors ?? []).map((e: {Message: string}) => e.Message);
}
function expectNoContainerErrors(errors: string[]): void {
const containerErrors = errors.filter(e => e.includes("Container image"));
expect(containerErrors).toHaveLength(0);
}
function expectContainerError(errors: string[], count = 1): void {
const containerErrors = errors.filter(e => e.includes("Container image cannot be empty"));
expect(containerErrors).toHaveLength(count);
}
describe("container image validation", () => {
describe("shorthand form", () => {
it("container: '' is silent for job container", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container: ''
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("container: docker:// errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container: docker://
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("container: valid-image passes", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container: ubuntu:16.04
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
});
describe("mapping form", () => {
it("container image: '' errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image: ''
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("container image: docker:// errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image: docker://
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("container: {} (empty object, missing image) errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container: {}
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("container image: null errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image:
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("empty image with expression in other field still errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image: ''
options: \${{ matrix.opts }}
steps:
- run: echo hi`);
expectContainerError(errors);
});
});
describe("services shorthand", () => {
it("services svc: '' errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc: ''
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("services svc: docker:// errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc: docker://
steps:
- run: echo hi`);
expectContainerError(errors);
});
});
describe("services mapping", () => {
it("services svc image: '' errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc:
image: ''
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("services svc image: docker:// errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc:
image: docker://
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("services svc: {} (empty object) errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc: {}
steps:
- run: echo hi`);
expectContainerError(errors);
});
it("empty image with expression sibling service still errors", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
svc1:
image: ''
svc2: \${{ matrix.svc }}
steps:
- run: echo hi`);
expectContainerError(errors);
});
});
describe("expression safety", () => {
it("container: expression skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container: \${{ matrix.container }}
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("container image: expression skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image: \${{ matrix.image }}
options: --privileged
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("container with expression key skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
\${{ vars.KEY }}: ubuntu
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("services: expression skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services: \${{ fromJSON(inputs.services) }}
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("services with expression alias key skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
\${{ matrix.alias }}: postgres
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("services container with expression key skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
db:
\${{ vars.KEY }}: postgres
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("container with all expression fields skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
container:
image: \${{ matrix.image }}
options: \${{ matrix.options }}
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("services svc: expression skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
db: \${{ matrix.db }}
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
it("services image: expression skips validation", async () => {
const errors = await getErrors(`on: push
jobs:
build:
runs-on: linux
services:
db:
image: \${{ matrix.db_image }}
options: --health-cmd pg_isready
steps:
- run: echo hi`);
expectNoContainerErrors(errors);
});
});
});
@@ -1,17 +1,199 @@
import {FeatureFlags} from "@actions/expressions";
import {TemplateContext} from "../../templates/template-context.js";
import {MappingToken, SequenceToken, StringToken, TemplateToken} from "../../templates/tokens/index.js";
import {isString} from "../../templates/tokens/type-guards.js";
import {Container, Credential} from "../workflow-template.js";
export function convertToJobContainer(context: TemplateContext, container: TemplateToken): Container | undefined {
function getFeatureFlags(context: TemplateContext): FeatureFlags | undefined {
return context.state["featureFlags"] as FeatureFlags | undefined;
}
const DOCKER_URI_PREFIX = "docker://";
function isEmptyImage(value: string): boolean {
const trimmed = value.startsWith(DOCKER_URI_PREFIX) ? value.substring(DOCKER_URI_PREFIX.length) : value;
return trimmed.length === 0;
}
export function convertToJobContainer(
context: TemplateContext,
container: TemplateToken,
isServiceContainer = false
): Container | undefined {
// Feature flag guard — use legacy implementation when flag is off
if (!getFeatureFlags(context)?.isEnabled("containerImageValidation")) {
return convertToJobContainerLegacy(context, container);
}
if (container.isExpression) {
return;
}
// Shorthand form
if (isString(container)) {
const image = container.assertString("container item");
if (!image || image.value.length === 0) {
if (isServiceContainer) {
context.error(container, "Container image cannot be empty");
}
return;
}
if (isEmptyImage(image.value)) {
context.error(container, "Container image cannot be empty");
return;
}
return {image};
}
// Mapping form
const mapping = container.assertMapping("container item");
if (!mapping) {
return;
}
let image: StringToken | undefined;
let env: MappingToken | undefined;
let ports: SequenceToken | undefined;
let volumes: SequenceToken | undefined;
let options: StringToken | undefined;
let credentials: Credential | undefined;
let hasExpressionKey = false;
let hasExpression = false;
for (const item of mapping) {
if (item.key.isExpression) {
hasExpressionKey = true;
continue;
}
const key = item.key.assertString("container item key");
switch (key.value) {
case "image":
if (item.value.isExpression) {
hasExpression = true;
break;
}
image = item.value.assertString("container image");
break;
case "credentials":
if (!item.value.isExpression) {
credentials = convertCredentials(context, item.value);
}
break;
case "env":
if (!item.value.isExpression) {
env = item.value.assertMapping("container env");
}
break;
case "ports":
if (!item.value.isExpression) {
ports = item.value.assertSequence("container ports");
}
break;
case "volumes":
if (!item.value.isExpression) {
volumes = item.value.assertSequence("container volumes");
}
break;
case "options":
if (!item.value.isExpression) {
options = item.value.assertString("container options");
}
break;
default:
context.error(key, `Unexpected container item key: ${key.value}`);
}
}
// Validate image
if (image) {
if (isEmptyImage(image.value)) {
context.error(image, "Container image cannot be empty");
return;
}
return {image, credentials, env, ports, volumes, options};
}
// No image key — skip error if expression keys could provide one
if (!hasExpressionKey && !hasExpression) {
context.error(container, "Container image cannot be empty");
}
}
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
// Feature flag guard — use legacy implementation when flag is off
if (!getFeatureFlags(context)?.isEnabled("containerImageValidation")) {
return convertToJobServicesLegacy(context, services);
}
if (services.isExpression) {
return;
}
const serviceList: Container[] = [];
const mapping = services.assertMapping("services");
for (const service of mapping) {
if (service.key.isExpression) {
continue;
}
service.key.assertString("service key");
const container = convertToJobContainer(context, service.value, true);
if (container) {
serviceList.push(container);
}
}
return serviceList;
}
function convertCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
const mapping = value.assertMapping("credentials");
if (!mapping) {
return;
}
let username: StringToken | undefined;
let password: StringToken | undefined;
for (const item of mapping) {
if (item.key.isExpression) {
continue;
}
const key = item.key.assertString("credentials item");
if (item.value.isExpression) {
continue;
}
switch (key.value) {
case "username":
username = item.value.assertString("credentials username");
break;
case "password":
password = item.value.assertString("credentials password");
break;
default:
context.error(key, `credentials key ${key.value}`);
}
}
return {username, password};
}
// ===== Legacy implementations (remove when containerImageValidation graduates) =====
function convertToJobContainerLegacy(context: TemplateContext, container: TemplateToken): Container | undefined {
let image: StringToken | undefined;
let env: MappingToken | undefined;
let ports: SequenceToken | undefined;
let volumes: SequenceToken | undefined;
let options: StringToken | undefined;
// Skip validation for expressions for now to match
// behavior of the other parsers
for (const [, token] of TemplateToken.traverse(container)) {
if (token.isExpression) {
return;
@@ -19,7 +201,6 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
}
if (isString(container)) {
// Workflow uses shorthand syntax `container: image-name`
image = container.assertString("container item");
return {image: image};
}
@@ -35,7 +216,7 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
image = value.assertString("container image");
break;
case "credentials":
convertToJobCredentials(context, value);
convertToJobCredentialsLegacy(context, value);
break;
case "env":
env = value.assertMapping("container env");
@@ -70,13 +251,13 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
}
}
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
function convertToJobServicesLegacy(context: TemplateContext, services: TemplateToken): Container[] | undefined {
const serviceList: Container[] = [];
const mapping = services.assertMapping("services");
for (const service of mapping) {
service.key.assertString("service key");
const container = convertToJobContainer(context, service.value);
const container = convertToJobContainerLegacy(context, service.value);
if (container) {
serviceList.push(container);
}
@@ -84,7 +265,7 @@ export function convertToJobServices(context: TemplateContext, services: Templat
return serviceList;
}
function convertToJobCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
function convertToJobCredentialsLegacy(context: TemplateContext, value: TemplateToken): Credential | undefined {
const mapping = value.assertMapping("credentials");
let username: StringToken | undefined;
@@ -136,3 +136,32 @@ function walkTreeToFindStatusFunctionCalls(tree: Expr | undefined): boolean {
return false;
}
/**
* Validates a pre-if or post-if condition string.
* Unlike step if conditions, pre-if and post-if are evaluated as-is by the runner
* (they default to always() only when the field is missing entirely).
* This function validates the expression and reports errors through the context.
*
* @param context The template context for error reporting
* @param token The token containing the condition
* @param condition The condition string to validate
* @returns The validated condition string, or undefined on error
*/
export function validateRunsIfCondition(
context: TemplateContext,
token: TemplateToken,
condition: string
): string | undefined {
const allowedContext = token.definitionInfo?.allowedContext || [];
// Validate the expression directly - no wrapping needed for pre-if/post-if
try {
ExpressionToken.validateExpression(condition, allowedContext);
} catch (err) {
context.error(token, err as Error);
return undefined;
}
return condition;
}
+2 -2
View File
@@ -50,7 +50,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
break;
case "container":
convertToJobContainer(context, item.value);
handleTemplateTokenErrors(item.value, context, undefined, () => convertToJobContainer(context, item.value));
container = item.value;
break;
@@ -103,7 +103,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
break;
case "services":
convertToJobServices(context, item.value);
handleTemplateTokenErrors(item.value, context, undefined, () => convertToJobServices(context, item.value));
services = item.value;
break;
@@ -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;
}
}
+4 -4
View File
@@ -2172,7 +2172,7 @@
}
},
"step-uses": {
"description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image.",
"description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, a [private repository with access enabled](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository), or in a published Docker container image.",
"string": {
"require-non-empty": true
}
@@ -2345,11 +2345,11 @@
"mapping": {
"properties": {
"image": {
"type": "non-empty-string",
"type": "string",
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
},
"options": {
"type": "non-empty-string",
"type": "string",
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
},
"env": "container-env",
@@ -2390,7 +2390,7 @@
"matrix"
],
"one-of": [
"non-empty-string",
"string",
"container-mapping"
]
},
@@ -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`);
+1
View File
@@ -91,3 +91,4 @@ yaml-schema-sequence.yml
yaml-schema-str-flow-styles.yml
yaml-schema-string.yml
yaml-schema-timestamp.yml
job-container-invalid.yml