Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71ff7b49c3 | |||
| 1a42526360 | |||
| 1cfe9f9f86 | |||
| 6641228870 | |||
| c1ad4d14df | |||
| 6a47895521 | |||
| c67c353245 | |||
| c6d2036302 | |||
| 56ce46afa6 | |||
| e3b56c2416 | |||
| d2ffb50a92 |
@@ -4,6 +4,9 @@ lerna-debug.log
|
||||
node_modules
|
||||
.DS_Store
|
||||
|
||||
# Nx cache (generated by Lerna/Nx)
|
||||
.nx/
|
||||
|
||||
# Minified JSON (generated at build time)
|
||||
*.min.json
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.30",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -36,7 +36,7 @@
|
||||
"clean": "rimraf dist",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.30",
|
||||
"version": "0.3.34",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -36,7 +36,7 @@
|
||||
"clean": "rimraf dist",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
@@ -48,8 +48,8 @@
|
||||
"actions-languageserver": "./bin/actions-languageserver"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.30",
|
||||
"@actions/workflow-parser": "^0.3.30",
|
||||
"@actions/languageservice": "^0.3.34",
|
||||
"@actions/workflow-parser": "^0.3.34",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
|
||||
@@ -20,18 +20,18 @@ import {
|
||||
TextDocumentSyncKind
|
||||
} from "vscode-languageserver";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {getClient} from "./client";
|
||||
import {Commands} from "./commands";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {descriptionProvider} from "./description-provider";
|
||||
import {getFileProvider} from "./file-provider";
|
||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
|
||||
import {onCompletion} from "./on-completion";
|
||||
import {ReadFileRequest, Requests} from "./request";
|
||||
import {getActionsMetadataProvider} from "./utils/action-metadata";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {timeOperation} from "./utils/timer";
|
||||
import {valueProviders} from "./value-providers";
|
||||
import {getClient} from "./client.js";
|
||||
import {Commands} from "./commands.js";
|
||||
import {contextProviders} from "./context-providers.js";
|
||||
import {descriptionProvider} from "./description-provider.js";
|
||||
import {getFileProvider} from "./file-provider.js";
|
||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions.js";
|
||||
import {onCompletion} from "./on-completion.js";
|
||||
import {ReadFileRequest, Requests} from "./request.js";
|
||||
import {getActionsMetadataProvider} from "./utils/action-metadata.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
import {timeOperation} from "./utils/timer.js";
|
||||
import {valueProviders} from "./value-providers.js";
|
||||
|
||||
export function initConnection(connection: Connection) {
|
||||
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -84,13 +84,17 @@ it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
|
||||
|
||||
// Get the step context
|
||||
const stepContext = stepsContext?.get("cache-primes");
|
||||
expect(stepContext).toBeDefined();
|
||||
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
||||
if (!stepContext) {
|
||||
throw new Error("Expected stepContext to be defined");
|
||||
}
|
||||
expect(isDescriptionDictionary(stepContext)).toBe(true);
|
||||
|
||||
// Get the outputs - should be a dictionary, not null
|
||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||
expect(outputs).toBeDefined();
|
||||
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
||||
if (!outputs) {
|
||||
throw new Error("Expected outputs to be defined");
|
||||
}
|
||||
expect(isDescriptionDictionary(outputs)).toBe(true);
|
||||
|
||||
// Outputs should be marked incomplete to allow dynamic outputs
|
||||
const outputsDict = outputs as DescriptionDictionary;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,12 +2,12 @@ import {complete} from "@actions/languageservice/complete";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {getFileProvider} from "./file-provider";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {Requests} from "./request";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {valueProviders} from "./value-providers";
|
||||
import {contextProviders} from "./context-providers.js";
|
||||
import {getFileProvider} from "./file-provider.js";
|
||||
import {RepositoryContext} from "./initializationOptions.js";
|
||||
import {Requests} from "./request.js";
|
||||
import {TTLCache} from "./utils/cache.js";
|
||||
import {valueProviders} from "./value-providers.js";
|
||||
|
||||
export async function onCompletion(
|
||||
connection: Connection,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import {fetchActionMetadata} from "./action-metadata";
|
||||
import {TTLCache} from "./cache";
|
||||
import {fetchActionMetadata} from "./action-metadata.js";
|
||||
import {TTLCache} from "./cache.js";
|
||||
|
||||
// A simplified version of the action.yml file from actions/checkout
|
||||
const actionMetadataContent = `
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "./dist"
|
||||
"outDir": "./dist",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.30",
|
||||
"version": "0.3.34",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf dist",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
|
||||
"prebuild": "npm run minify-json",
|
||||
@@ -47,8 +47,8 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.30",
|
||||
"@actions/workflow-parser": "^0.3.30",
|
||||
"@actions/expressions": "^0.3.34",
|
||||
"@actions/workflow-parser": "^0.3.34",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {complete} from "./complete";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("complete action files", () => {
|
||||
function createActionDocument(
|
||||
content: string,
|
||||
uri = "file:///test/action.yml"
|
||||
): [TextDocument, {line: number; character: number}] {
|
||||
// Parse cursor position and remove the | character
|
||||
const cursorIndex = content.indexOf("|");
|
||||
if (cursorIndex === -1) {
|
||||
throw new Error("No cursor (|) found in content");
|
||||
}
|
||||
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
|
||||
const doc = TextDocument.create(uri, "yaml", 1, newContent);
|
||||
const position = doc.positionAt(cursorIndex);
|
||||
return [doc, position];
|
||||
}
|
||||
|
||||
describe("expression completion in composite actions", () => {
|
||||
it("completes inputs context", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
inputs:
|
||||
name:
|
||||
description: The name
|
||||
greeting:
|
||||
description: The greeting
|
||||
default: Hello
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "\${{ inputs.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("name");
|
||||
expect(labels).toContain("greeting");
|
||||
});
|
||||
|
||||
it("completes steps context with prior step IDs", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: step1
|
||||
run: echo "hello"
|
||||
shell: bash
|
||||
- id: step2
|
||||
run: echo "\${{ steps.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("step1");
|
||||
expect(labels).not.toContain("step2"); // Current step should not be included
|
||||
});
|
||||
|
||||
it("completes step properties", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: greet
|
||||
run: echo "hello"
|
||||
shell: bash
|
||||
- run: echo "\${{ steps.greet.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("outputs");
|
||||
expect(labels).toContain("outcome");
|
||||
expect(labels).toContain("conclusion");
|
||||
});
|
||||
|
||||
it("does not include steps from after cursor position", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: first
|
||||
run: echo "first"
|
||||
shell: bash
|
||||
- run: echo "\${{ steps.| }}"
|
||||
shell: bash
|
||||
- id: last
|
||||
run: echo "last"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("first");
|
||||
expect(labels).not.toContain("last");
|
||||
});
|
||||
|
||||
it("completes github context in actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "\${{ github.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("actor");
|
||||
expect(labels).toContain("repository");
|
||||
expect(labels).toContain("ref");
|
||||
});
|
||||
|
||||
it("completes runner context in actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "\${{ runner.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("os");
|
||||
expect(labels).toContain("arch");
|
||||
expect(labels).toContain("temp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("top-level completions", () => {
|
||||
it("completes top-level keys", async () => {
|
||||
const [doc, position] = createActionDocument(`n|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("name");
|
||||
});
|
||||
|
||||
it("completes at empty line", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("description");
|
||||
expect(labels).toContain("runs");
|
||||
expect(labels).toContain("inputs");
|
||||
expect(labels).toContain("outputs");
|
||||
expect(labels).toContain("branding");
|
||||
expect(labels).toContain("author");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs completions", () => {
|
||||
it("completes runs.using values", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: |`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("composite");
|
||||
expect(labels).toContain("node20");
|
||||
expect(labels).toContain("docker");
|
||||
});
|
||||
|
||||
it("completes runs keys", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("using");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding completions", () => {
|
||||
it("completes branding keys", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
branding:
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("icon");
|
||||
expect(labels).toContain("color");
|
||||
});
|
||||
|
||||
it("completes branding color values", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
branding:
|
||||
color: |`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("blue");
|
||||
expect(labels).toContain("green");
|
||||
expect(labels).toContain("red");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inputs completions", () => {
|
||||
it("completes input property keys", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
inputs:
|
||||
my-input:
|
||||
|
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("description");
|
||||
expect(labels).toContain("required");
|
||||
expect(labels).toContain("default");
|
||||
expect(labels).toContain("deprecationMessage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("document type routing", () => {
|
||||
it("routes action.yml to action completion", async () => {
|
||||
const [doc, position] = createActionDocument(`n|`, "file:///my-repo/action.yml");
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("name");
|
||||
// Should NOT contain workflow-specific keys
|
||||
expect(labels).not.toContain("on");
|
||||
expect(labels).not.toContain("jobs");
|
||||
});
|
||||
|
||||
it("includes descriptions from schema for completion items", async () => {
|
||||
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const authorCompletion = completions.find(c => c.label === "author");
|
||||
expect(authorCompletion).toBeDefined();
|
||||
expect(authorCompletion?.documentation).toBeDefined();
|
||||
expect((authorCompletion?.documentation as {value: string})?.value).toContain("author");
|
||||
});
|
||||
|
||||
it("includes descriptions for branding completion", async () => {
|
||||
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const brandingCompletion = completions.find(c => c.label === "branding");
|
||||
expect(brandingCompletion).toBeDefined();
|
||||
expect(brandingCompletion?.documentation).toBeDefined();
|
||||
expect((brandingCompletion?.documentation as {value: string})?.value).toContain("branding");
|
||||
});
|
||||
|
||||
it("falls back to type description when property has no description", async () => {
|
||||
// `inputs` uses shorthand form in schema: "inputs": "inputs-strict"
|
||||
// So the property has no description, but the type `inputs-strict` does
|
||||
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
|
||||
const completions = await complete(doc, position);
|
||||
|
||||
const inputsCompletion = completions.find(c => c.label === "inputs");
|
||||
expect(inputsCompletion).toBeDefined();
|
||||
expect(inputsCompletion?.documentation).toBeDefined();
|
||||
expect((inputsCompletion?.documentation as {value: string})?.value).toContain("Input parameters");
|
||||
});
|
||||
|
||||
it("does not route workflow files to action completion", async () => {
|
||||
const doc = TextDocument.create("file:///repo/.github/workflows/ci.yml", "yaml", 1, `o`);
|
||||
const completions = await complete(doc, {line: 0, character: 1});
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("on");
|
||||
expect(labels).toContain("jobs");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -465,7 +465,7 @@ jobs:
|
||||
]);
|
||||
|
||||
// One-of (scalar variant)
|
||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined);
|
||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
|
||||
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
||||
});
|
||||
|
||||
@@ -489,7 +489,7 @@ jobs:
|
||||
]);
|
||||
|
||||
// One-of (scalar variant)
|
||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.detail === undefined);
|
||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
|
||||
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
||||
});
|
||||
});
|
||||
@@ -530,7 +530,7 @@ jobs:
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Scalar variant inserts "types: "
|
||||
const scalarVariant = result.find(x => x.label === "types" && x.detail === undefined);
|
||||
const scalarVariant = result.find(x => x.label === "types" && x.labelDetails === undefined);
|
||||
expect(scalarVariant?.textEdit?.newText).toEqual("types: ");
|
||||
});
|
||||
|
||||
@@ -586,8 +586,8 @@ jobs:
|
||||
|
||||
// Should have both check_run (scalar) and check_run with detail "full syntax"
|
||||
const checkRunVariants = result.filter(x => x.label === "check_run");
|
||||
expect(checkRunVariants.some(x => x.detail === undefined)).toBe(true);
|
||||
expect(checkRunVariants.some(x => x.detail === "full syntax")).toBe(true);
|
||||
expect(checkRunVariants.some(x => x.labelDetails === undefined)).toBe(true);
|
||||
expect(checkRunVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
|
||||
@@ -602,9 +602,9 @@ jobs:
|
||||
// Should have runs-on (scalar), runs-on with detail "list", and runs-on with detail "full syntax"
|
||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
||||
expect(runsOnVariants.length).toBe(3);
|
||||
expect(runsOnVariants.some(x => x.detail === undefined)).toBe(true);
|
||||
expect(runsOnVariants.some(x => x.detail === "list")).toBe(true);
|
||||
expect(runsOnVariants.some(x => x.detail === "full syntax")).toBe(true);
|
||||
expect(runsOnVariants.some(x => x.labelDetails === undefined)).toBe(true);
|
||||
expect(runsOnVariants.some(x => x.labelDetails?.description === "list")).toBe(true);
|
||||
expect(runsOnVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
|
||||
});
|
||||
|
||||
it("generates correct insertText for one-of variants in parent mode", async () => {
|
||||
@@ -619,13 +619,17 @@ jobs:
|
||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
||||
|
||||
// Scalar: just key with colon and space
|
||||
expect(runsOnVariants.find(x => x.detail === undefined)?.textEdit?.newText).toEqual("runs-on: ");
|
||||
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.textEdit?.newText).toEqual("runs-on: ");
|
||||
|
||||
// Sequence: key with colon, newline, and list item
|
||||
expect(runsOnVariants.find(x => x.detail === "list")?.textEdit?.newText).toEqual("runs-on:\n - ");
|
||||
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.textEdit?.newText).toEqual(
|
||||
"runs-on:\n - "
|
||||
);
|
||||
|
||||
// Mapping: key with colon, newline, and indentation for nested keys
|
||||
expect(runsOnVariants.find(x => x.detail === "full syntax")?.textEdit?.newText).toEqual("runs-on:\n ");
|
||||
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.textEdit?.newText).toEqual(
|
||||
"runs-on:\n "
|
||||
);
|
||||
});
|
||||
|
||||
it("generates correct insertText for one-of variants in parent mode", async () => {
|
||||
@@ -654,11 +658,11 @@ jobs:
|
||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
||||
|
||||
// Scalar: no sortText needed (sorts naturally first)
|
||||
expect(runsOnVariants.find(x => x.detail === undefined)?.sortText).toBeUndefined();
|
||||
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.sortText).toBeUndefined();
|
||||
|
||||
// Sequence and mapping: sortText controls ordering
|
||||
expect(runsOnVariants.find(x => x.detail === "list")?.sortText).toEqual("runs-on 1");
|
||||
expect(runsOnVariants.find(x => x.detail === "full syntax")?.sortText).toEqual("runs-on 2");
|
||||
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.sortText).toEqual("runs-on 1");
|
||||
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.sortText).toEqual("runs-on 2");
|
||||
});
|
||||
|
||||
it("scalar event completion inserts inline without newline", async () => {
|
||||
@@ -672,13 +676,13 @@ jobs:
|
||||
const push = result.find(x => x.label === "push");
|
||||
expect(push?.textEdit?.newText).toEqual("push");
|
||||
|
||||
const checkRun = result.find(x => x.label === "check_run" && x.detail === undefined);
|
||||
const checkRun = result.find(x => x.label === "check_run" && x.labelDetails === undefined);
|
||||
expect(checkRun?.textEdit?.newText).toEqual("check_run");
|
||||
|
||||
// Full syntax form should NOT be shown in Key mode - it requires a newline
|
||||
// which is confusing when typing inline. Users who want the mapping form
|
||||
// can use `on (full syntax)` at the parent level.
|
||||
expect(result.find(x => x.label === "check_run" && x.detail === "full syntax")).toBeUndefined();
|
||||
expect(result.find(x => x.label === "check_run" && x.labelDetails?.description === "full syntax")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters to sequence options when user has started a sequence", async () => {
|
||||
@@ -719,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 () => {
|
||||
@@ -820,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 - "
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+134
-59
@@ -1,9 +1,11 @@
|
||||
import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions";
|
||||
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
||||
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
|
||||
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
||||
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
|
||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
@@ -15,16 +17,23 @@ import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-sch
|
||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
import {getContext, Mode} from "./context-providers/default.js";
|
||||
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
|
||||
import {ActionContext, getActionContext} from "./context/action-context.js";
|
||||
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
|
||||
import {validatorFunctions} from "./expression-validation/functions.js";
|
||||
import {error} from "./log.js";
|
||||
import {detectDocumentType} from "./utils/document-type.js";
|
||||
import {isPotentiallyExpression} from "./utils/expression-detection.js";
|
||||
import {findToken} from "./utils/find-token.js";
|
||||
import {guessIndentation} from "./utils/indentation-guesser.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {isPlaceholder, transform} from "./utils/transform.js";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {
|
||||
getOrConvertActionTemplate,
|
||||
getOrConvertWorkflowTemplate,
|
||||
getOrParseAction,
|
||||
getOrParseWorkflow
|
||||
} from "./utils/workflow-cache.js";
|
||||
import {Value, ValueProviderConfig} from "./value-providers/config.js";
|
||||
import {defaultValueProviders} from "./value-providers/default.js";
|
||||
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
|
||||
@@ -68,45 +77,78 @@ export async function complete(
|
||||
content: newDoc.getText()
|
||||
};
|
||||
|
||||
const parsedWorkflow = fetchOrParseWorkflow(file, textDocument.uri, true);
|
||||
if (!parsedWorkflow.value) {
|
||||
// Determine document type - unknown defaults to workflow (backwards compatibility)
|
||||
const isAction = detectDocumentType(textDocument.uri) === "action";
|
||||
|
||||
// Parse the document
|
||||
const parsedTemplate = isAction
|
||||
? getOrParseAction(file, textDocument.uri, true)
|
||||
: getOrParseWorkflow(file, textDocument.uri, true);
|
||||
if (!parsedTemplate.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = await fetchOrConvertWorkflowTemplate(
|
||||
parsedWorkflow.context,
|
||||
parsedWorkflow.value,
|
||||
textDocument.uri,
|
||||
config,
|
||||
{
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
}
|
||||
);
|
||||
const schema = isAction ? getActionSchema() : getWorkflowSchema();
|
||||
const {token, keyToken, parent, path} = findToken(newPos, parsedTemplate.value);
|
||||
|
||||
const {token, keyToken, parent, path} = findToken(newPos, parsedWorkflow.value);
|
||||
const workflowContext = getWorkflowContext(textDocument.uri, template, path);
|
||||
// Build context for position-aware completions (e.g., steps.*, needs.*, inputs.*)
|
||||
let workflowContext: WorkflowContext | undefined;
|
||||
let actionContext: ActionContext | undefined;
|
||||
if (isAction) {
|
||||
const actionTemplate = getOrConvertActionTemplate(
|
||||
parsedTemplate.context,
|
||||
parsedTemplate.value,
|
||||
textDocument.uri,
|
||||
{errorPolicy: ErrorPolicy.TryConversion},
|
||||
true
|
||||
);
|
||||
actionContext = getActionContext(textDocument.uri, actionTemplate, path);
|
||||
} else {
|
||||
const workflowTemplate = await getOrConvertWorkflowTemplate(
|
||||
parsedTemplate.context,
|
||||
parsedTemplate.value,
|
||||
textDocument.uri,
|
||||
config,
|
||||
{
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
},
|
||||
true
|
||||
);
|
||||
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
|
||||
}
|
||||
|
||||
// If we are inside an expression, take a different code-path. The workflow parser does not correctly create
|
||||
// expression nodes for invalid expressions and during editing expressions are invalid most of the time.
|
||||
if (token) {
|
||||
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const context = await getContext(allowedContext, config?.contextProviderConfig, workflowContext, Mode.Completion);
|
||||
// Expression completions
|
||||
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const context = isAction
|
||||
? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion)
|
||||
: await getWorkflowExpressionContext(
|
||||
allowedContext,
|
||||
config?.contextProviderConfig,
|
||||
workflowContext,
|
||||
Mode.Completion
|
||||
);
|
||||
|
||||
return getExpressionCompletionItems(token, context, newPos);
|
||||
}
|
||||
return getExpressionCompletionItems(token, context, newPos);
|
||||
}
|
||||
|
||||
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
|
||||
const indentString = " ".repeat(indentation.tabSize);
|
||||
|
||||
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
|
||||
// YAML key/value completions
|
||||
const values = await getValues(
|
||||
token,
|
||||
keyToken,
|
||||
parent,
|
||||
config?.valueProviderConfig,
|
||||
workflowContext,
|
||||
indentString,
|
||||
schema
|
||||
);
|
||||
|
||||
// Add escape hatch completions when completing an empty scalar value for a one-of field.
|
||||
// These provide a way out of "dead end" situations where no scalar completions exist
|
||||
// but alternative structural forms (list, mapping) are available.
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos);
|
||||
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
||||
values.push(...escapeHatches);
|
||||
|
||||
// Figure out what text to replace when the user picks a completion.
|
||||
@@ -136,6 +178,7 @@ export async function complete(
|
||||
}
|
||||
}
|
||||
|
||||
// Convert values to LSP CompletionItems
|
||||
return values.map(value => {
|
||||
const newText = value.insertText || value.label;
|
||||
|
||||
@@ -149,9 +192,15 @@ 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,
|
||||
detail: value.detail,
|
||||
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
|
||||
filterText: value.filterText,
|
||||
sortText: value.sortText,
|
||||
documentation: value.description && {
|
||||
@@ -159,7 +208,8 @@ export async function complete(
|
||||
value: value.description
|
||||
},
|
||||
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
||||
textEdit
|
||||
textEdit,
|
||||
additionalTextEdits
|
||||
};
|
||||
|
||||
return item;
|
||||
@@ -182,8 +232,9 @@ async function getValues(
|
||||
keyToken: TemplateToken | null,
|
||||
parent: TemplateToken | null,
|
||||
valueProviderConfig: ValueProviderConfig | undefined,
|
||||
workflowContext: WorkflowContext,
|
||||
indentation: string
|
||||
workflowContext: WorkflowContext | undefined,
|
||||
indentation: string,
|
||||
schema: TemplateSchema
|
||||
): Promise<Value[]> {
|
||||
if (!parent) {
|
||||
return [];
|
||||
@@ -194,20 +245,23 @@ async function getValues(
|
||||
// Use the value providers from the parent if the current key is null
|
||||
const valueProviderToken = keyToken || parent;
|
||||
|
||||
const customValueProvider =
|
||||
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
|
||||
if (customValueProvider) {
|
||||
const customValues = await customValueProvider.get(workflowContext, existingValues);
|
||||
if (customValues) {
|
||||
return filterAndSortCompletionOptions(customValues, existingValues);
|
||||
// Value providers require workflow context - only use them for workflows
|
||||
if (workflowContext) {
|
||||
const customValueProvider =
|
||||
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
|
||||
if (customValueProvider) {
|
||||
const customValues = await customValueProvider.get(workflowContext, existingValues);
|
||||
if (customValues) {
|
||||
return filterAndSortCompletionOptions(customValues, existingValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultValueProvider =
|
||||
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
|
||||
if (defaultValueProvider) {
|
||||
const values = await defaultValueProvider.get(workflowContext, existingValues);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
const defaultValueProvider =
|
||||
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
|
||||
if (defaultValueProvider) {
|
||||
const values = await defaultValueProvider.get(workflowContext, existingValues);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the definition if there are no value providers
|
||||
@@ -224,7 +278,8 @@ async function getValues(
|
||||
def,
|
||||
indentation,
|
||||
keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent,
|
||||
tokenStructure
|
||||
tokenStructure,
|
||||
schema
|
||||
);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
}
|
||||
@@ -284,7 +339,8 @@ function getEscapeHatchCompletions(
|
||||
token: TemplateToken | null,
|
||||
keyToken: TemplateToken | null,
|
||||
indentation: string,
|
||||
position: Position
|
||||
position: Position,
|
||||
schema: TemplateSchema
|
||||
): Value[] {
|
||||
// Only show escape hatches when value is empty
|
||||
const tokenStructure = getTokenStructure(token);
|
||||
@@ -299,7 +355,6 @@ function getEscapeHatchCompletions(
|
||||
|
||||
// Determine which structural types are available from the definition
|
||||
const def = keyToken.definition;
|
||||
const schema = getWorkflowSchema();
|
||||
const buckets = {
|
||||
sequence: false,
|
||||
mapping: false
|
||||
@@ -340,9 +395,19 @@ function getEscapeHatchCompletions(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate the range from key start to current position
|
||||
// This covers "key: " so we can replace it with "key:\n - " or "key:\n "
|
||||
const editRange = {
|
||||
// For VS Code compatibility, we use a cursor-position range for the main textEdit
|
||||
// and additionalTextEdits to clean up the key portion. This prevents VS Code from
|
||||
// filtering out escape hatches based on the key text (e.g., "runs-on: ").
|
||||
//
|
||||
// Main textEdit: insert at cursor position (newline + indented content)
|
||||
// additionalTextEdits: replace "key: " with "key:" (removes trailing space)
|
||||
const cursorRange = {
|
||||
start: {line: position.line, character: position.character},
|
||||
end: {line: position.line, character: position.character}
|
||||
};
|
||||
|
||||
// Range from key start to cursor - used to replace "key: " with "key:" in additionalTextEdits
|
||||
const keyToCursorRange = {
|
||||
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
|
||||
end: {line: position.line, character: position.character}
|
||||
};
|
||||
@@ -351,11 +416,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}:`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -363,11 +433,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}:`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getContext, Mode} from "./default.js";
|
||||
import {getWorkflowExpressionContext, Mode} from "./default.js";
|
||||
|
||||
describe("getContext", () => {
|
||||
describe("getWorkflowExpressionContext", () => {
|
||||
const emptyWorkflowContext: WorkflowContext = {
|
||||
uri: "test.yaml",
|
||||
template: undefined
|
||||
@@ -10,7 +10,7 @@ describe("getContext", () => {
|
||||
|
||||
describe("when no contextProviderConfig is provided", () => {
|
||||
it("should mark secrets context as incomplete", async () => {
|
||||
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||
expect(secretsContext).toBeDefined();
|
||||
@@ -18,7 +18,7 @@ describe("getContext", () => {
|
||||
});
|
||||
|
||||
it("should mark vars context as incomplete", async () => {
|
||||
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||
expect(varsContext).toBeDefined();
|
||||
@@ -26,7 +26,12 @@ describe("getContext", () => {
|
||||
});
|
||||
|
||||
it("should not mark other contexts as incomplete", async () => {
|
||||
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(
|
||||
["env", "github"],
|
||||
undefined,
|
||||
emptyWorkflowContext,
|
||||
Mode.Validation
|
||||
);
|
||||
|
||||
const envContext = result.get("env") as DescriptionDictionary;
|
||||
const githubContext = result.get("github") as DescriptionDictionary;
|
||||
@@ -48,7 +53,7 @@ describe("getContext", () => {
|
||||
getContext: () => Promise.resolve(providedContext)
|
||||
};
|
||||
|
||||
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets");
|
||||
expect(secretsContext).toBe(providedContext);
|
||||
@@ -63,7 +68,7 @@ describe("getContext", () => {
|
||||
getContext: () => Promise.resolve(providedContext)
|
||||
};
|
||||
|
||||
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars");
|
||||
expect(varsContext).toBe(providedContext);
|
||||
@@ -77,7 +82,7 @@ describe("getContext", () => {
|
||||
getContext: () => Promise.resolve(undefined)
|
||||
};
|
||||
|
||||
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||
expect(secretsContext.complete).toBe(false);
|
||||
@@ -88,7 +93,7 @@ describe("getContext", () => {
|
||||
getContext: () => Promise.resolve(undefined)
|
||||
};
|
||||
|
||||
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||
expect(varsContext.complete).toBe(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {Kind} from "@actions/expressions/data/expressiondata";
|
||||
import {ActionContext, getActionInputs, getActionStepIdsBefore} from "../context/action-context.js";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {ContextProviderConfig} from "./config.js";
|
||||
import {getDescription, RootContext} from "./descriptions.js";
|
||||
@@ -12,7 +13,6 @@ import {getMatrixContext} from "./matrix.js";
|
||||
import {getNeedsContext} from "./needs.js";
|
||||
import {getSecretsContext} from "./secrets.js";
|
||||
import {getStepsContext} from "./steps.js";
|
||||
import {getStrategyContext} from "./strategy.js";
|
||||
|
||||
// ContextValue is the type of the value returned by a context provider
|
||||
// Null indicates that the context provider doesn't have any value to provide
|
||||
@@ -24,10 +24,13 @@ export enum Mode {
|
||||
Hover
|
||||
}
|
||||
|
||||
export async function getContext(
|
||||
/**
|
||||
* Build expression context for workflow files (e.g., github.*, steps.*, needs.*)
|
||||
*/
|
||||
export async function getWorkflowExpressionContext(
|
||||
names: string[],
|
||||
config: ContextProviderConfig | undefined,
|
||||
workflowContext: WorkflowContext,
|
||||
workflowContext: WorkflowContext | undefined,
|
||||
mode: Mode
|
||||
): Promise<DescriptionDictionary> {
|
||||
const context = new DescriptionDictionary();
|
||||
@@ -41,7 +44,9 @@ export async function getContext(
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
|
||||
const remoteValue = workflowContext
|
||||
? await config?.getContext(contextName, value, workflowContext, mode)
|
||||
: undefined;
|
||||
if (remoteValue) {
|
||||
value = remoteValue;
|
||||
} else if (contextName === "secrets" || contextName === "vars") {
|
||||
@@ -57,61 +62,198 @@ export async function getContext(
|
||||
return context;
|
||||
}
|
||||
|
||||
function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: Mode): ContextValue | undefined {
|
||||
/**
|
||||
* Maps context name to its provider (e.g., "steps" -> getStepsContext)
|
||||
*/
|
||||
function getDefaultContext(
|
||||
name: string,
|
||||
workflowContext: WorkflowContext | undefined,
|
||||
mode: Mode
|
||||
): ContextValue | undefined {
|
||||
switch (name) {
|
||||
case "env":
|
||||
return getEnvContext(workflowContext);
|
||||
return workflowContext ? getEnvContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "github":
|
||||
return getGithubContext(workflowContext, mode);
|
||||
|
||||
case "inputs":
|
||||
return getInputsContext(workflowContext);
|
||||
return workflowContext ? getInputsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "reusableWorkflowJob":
|
||||
case "job":
|
||||
return getJobContext(workflowContext);
|
||||
return workflowContext ? getJobContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "jobs":
|
||||
return getJobsContext(workflowContext);
|
||||
return workflowContext ? getJobsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "matrix":
|
||||
return getMatrixContext(workflowContext, mode);
|
||||
return workflowContext ? getMatrixContext(workflowContext, mode) : new DescriptionDictionary();
|
||||
|
||||
case "needs":
|
||||
return getNeedsContext(workflowContext);
|
||||
return workflowContext ? getNeedsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "runner":
|
||||
return objectToDictionary({
|
||||
arch: "X64",
|
||||
debug: "1",
|
||||
environment: "github-hosted",
|
||||
name: "GitHub Actions 2",
|
||||
os: "Linux",
|
||||
temp: "/home/runner/work/_temp",
|
||||
tool_cache: "/opt/hostedtoolcache",
|
||||
workspace: "/home/runner/work/repo"
|
||||
});
|
||||
return getRunnerContext();
|
||||
|
||||
case "secrets":
|
||||
return getSecretsContext(workflowContext, mode);
|
||||
return workflowContext ? getSecretsContext(workflowContext, mode) : new DescriptionDictionary();
|
||||
|
||||
case "steps":
|
||||
return getStepsContext(workflowContext);
|
||||
return workflowContext ? getStepsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "strategy":
|
||||
return getStrategyContext(workflowContext);
|
||||
return getStrategyContext();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function objectToDictionary(object: {[key: string]: string}): DescriptionDictionary {
|
||||
const dictionary = new DescriptionDictionary();
|
||||
/**
|
||||
* Returns the strategy context with default values (fail-fast, job-index, etc.)
|
||||
*/
|
||||
function getStrategyContext(): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
|
||||
return new DescriptionDictionary(
|
||||
{key: "fail-fast", value: new data.BooleanData(true), description: getDescription("strategy", "fail-fast")},
|
||||
{key: "job-index", value: new data.NumberData(0), description: getDescription("strategy", "job-index")},
|
||||
{key: "job-total", value: new data.NumberData(1), description: getDescription("strategy", "job-total")},
|
||||
{key: "max-parallel", value: new data.NumberData(1), description: getDescription("strategy", "max-parallel")}
|
||||
);
|
||||
}
|
||||
|
||||
for (const key in object) {
|
||||
dictionary.add(key, new data.StringData(object[key]));
|
||||
/**
|
||||
* Returns the runner context with environment info (arch, os, temp, workspace, etc.)
|
||||
*/
|
||||
function getRunnerContext(): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
|
||||
return new DescriptionDictionary(
|
||||
{key: "arch", value: new data.StringData("X64"), description: getDescription("runner", "arch")},
|
||||
{key: "debug", value: new data.StringData("1"), description: getDescription("runner", "debug")},
|
||||
{
|
||||
key: "environment",
|
||||
value: new data.StringData("github-hosted"),
|
||||
description: getDescription("runner", "environment")
|
||||
},
|
||||
{key: "name", value: new data.StringData("GitHub Actions 2"), description: getDescription("runner", "name")},
|
||||
{key: "os", value: new data.StringData("Linux"), description: getDescription("runner", "os")},
|
||||
{key: "temp", value: new data.StringData("/home/runner/work/_temp"), description: getDescription("runner", "temp")},
|
||||
{
|
||||
key: "tool_cache",
|
||||
value: new data.StringData("/opt/hostedtoolcache"),
|
||||
description: getDescription("runner", "tool_cache")
|
||||
},
|
||||
{
|
||||
key: "workspace",
|
||||
value: new data.StringData("/home/runner/work/repo"),
|
||||
description: getDescription("runner", "workspace")
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context for expression completion in action.yml files.
|
||||
* Actions have a more limited set of contexts available compared to workflows.
|
||||
*/
|
||||
export function getActionExpressionContext(
|
||||
names: string[],
|
||||
config: ContextProviderConfig | undefined,
|
||||
actionContext: ActionContext | undefined,
|
||||
mode: Mode
|
||||
): DescriptionDictionary {
|
||||
const context = new DescriptionDictionary();
|
||||
|
||||
for (const contextName of names) {
|
||||
const value = getDefaultActionContext(contextName, actionContext, mode);
|
||||
if (value) {
|
||||
context.add(contextName, value, getDescription(RootContext, contextName));
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps context name to its provider for action.yml files (e.g., "inputs" -> getActionInputsContext)
|
||||
*/
|
||||
function getDefaultActionContext(
|
||||
name: string,
|
||||
actionContext: ActionContext | undefined,
|
||||
mode: Mode
|
||||
): ContextValue | undefined {
|
||||
switch (name) {
|
||||
case "inputs":
|
||||
// Return empty dictionary if no context - still allows completion, just without specific input names
|
||||
return actionContext ? getActionInputsContext(actionContext) : new DescriptionDictionary();
|
||||
|
||||
case "steps":
|
||||
// Return empty dictionary if no context - still allows completion, just without specific step IDs
|
||||
return actionContext ? getActionStepsContext(actionContext) : new DescriptionDictionary();
|
||||
|
||||
case "github":
|
||||
// Use the same github context but without workflow-specific event info
|
||||
// Actions inherit the event context from the calling workflow at runtime
|
||||
return getGithubContext(undefined, mode);
|
||||
|
||||
case "runner":
|
||||
return getRunnerContext();
|
||||
|
||||
case "env":
|
||||
// Actions can access env but we don't have runtime values
|
||||
return new DescriptionDictionary();
|
||||
|
||||
case "job": {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||
const jobContext = new DescriptionDictionary();
|
||||
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
|
||||
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
|
||||
const containerContext = new DescriptionDictionary();
|
||||
containerContext.add("id", new data.StringData(""), getDescription("job", "container.id"));
|
||||
containerContext.add("network", new data.StringData(""), getDescription("job", "container.network"));
|
||||
jobContext.add("container", containerContext, getDescription("job", "container"));
|
||||
jobContext.add("services", new DescriptionDictionary(), getDescription("job", "services"));
|
||||
return jobContext;
|
||||
}
|
||||
|
||||
case "strategy":
|
||||
return getStrategyContext();
|
||||
|
||||
case "matrix":
|
||||
// Actions can access matrix context at runtime
|
||||
return new DescriptionDictionary();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inputs context for action files based on defined inputs
|
||||
*/
|
||||
function getActionInputsContext(actionContext: ActionContext): DescriptionDictionary {
|
||||
const dict = new DescriptionDictionary();
|
||||
const inputs = getActionInputs(actionContext.template);
|
||||
|
||||
for (const input of inputs) {
|
||||
dict.add(input.id, new data.StringData(""), input.description || "");
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get steps context for composite action files based on step IDs
|
||||
*/
|
||||
function getActionStepsContext(actionContext: ActionContext): DescriptionDictionary {
|
||||
const dict = new DescriptionDictionary();
|
||||
const stepIds = getActionStepIdsBefore(actionContext);
|
||||
|
||||
for (const stepId of stepIds) {
|
||||
const stepDict = new DescriptionDictionary();
|
||||
stepDict.add("outputs", new DescriptionDictionary(), getDescription("steps", "outputs"));
|
||||
stepDict.add("outcome", new data.StringData("success"), getDescription("steps", "outcome"));
|
||||
stepDict.add("conclusion", new data.StringData("success"), getDescription("steps", "conclusion"));
|
||||
dict.add(stepId, stepDict, `Step: ${stepId}`);
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
@@ -198,6 +198,35 @@
|
||||
"description": "The default working directory on the runner for steps, and the default location of your repository when using the [`checkout`](https://github.com/actions/checkout) action."
|
||||
}
|
||||
},
|
||||
"job": {
|
||||
"container": {
|
||||
"description": "Information about the job's container. For more information about containers, see \"[Running jobs in a container](https://docs.github.com/actions/using-jobs/running-jobs-in-a-container).\""
|
||||
},
|
||||
"container.id": {
|
||||
"description": "The ID of the container."
|
||||
},
|
||||
"container.network": {
|
||||
"description": "The ID of the container network. The runner creates the network used by all containers in a job."
|
||||
},
|
||||
"services": {
|
||||
"description": "The service containers created for a job. For more information about service containers, see \"[Using service containers](https://docs.github.com/actions/using-containerized-services/about-service-containers).\""
|
||||
},
|
||||
"services.<service_id>.id": {
|
||||
"description": "The ID of the service container."
|
||||
},
|
||||
"services.<service_id>.network": {
|
||||
"description": "The ID of the service container network. The runner creates the network used by all containers in a job."
|
||||
},
|
||||
"services.<service_id>.ports": {
|
||||
"description": "The exposed ports of the service container."
|
||||
},
|
||||
"status": {
|
||||
"description": "The current status of the job. Possible values are `success`, `failure`, or `cancelled`."
|
||||
},
|
||||
"check_run_id": {
|
||||
"description": "The unique identifier of the check run for this job."
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"GITHUB_TOKEN": {
|
||||
"description": "Automatically created token for each workflow run. For more information, see \"[Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\""
|
||||
|
||||
@@ -7,7 +7,10 @@ import {getDescription} from "./descriptions.js";
|
||||
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads.js";
|
||||
import {getInputsContext} from "./inputs.js";
|
||||
|
||||
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
|
||||
/**
|
||||
* Returns the github context with properties like actor, ref, sha, event, etc.
|
||||
*/
|
||||
export function getGithubContext(workflowContext: WorkflowContext | undefined, mode: Mode): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
const keys = [
|
||||
"action",
|
||||
@@ -73,7 +76,10 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
);
|
||||
}
|
||||
|
||||
function getEventContext(workflowContext: WorkflowContext, mode: Mode): ExpressionData {
|
||||
/**
|
||||
* Builds the github.event context based on workflow trigger configuration.
|
||||
*/
|
||||
function getEventContext(workflowContext: WorkflowContext | undefined, mode: Mode): ExpressionData {
|
||||
const d = new DescriptionDictionary();
|
||||
const eventsConfig = workflowContext?.template?.events;
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getJobContext} from "./job.js";
|
||||
|
||||
function stringToToken(value: string): StringToken {
|
||||
return new StringToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
describe("job context", () => {
|
||||
it("returns empty context when no job", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
// When there's no job, context is empty
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns status and check_run_id when job has no container or services", () => {
|
||||
const workflowContext = {job: {}} as WorkflowContext;
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
expect(context.get("status")).toBeDefined();
|
||||
expect(context.get("check_run_id")).toBeDefined();
|
||||
expect(context.get("container")).toBeUndefined();
|
||||
expect(context.get("services")).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("container context", () => {
|
||||
it("includes container with id and network when container is defined", () => {
|
||||
const containerToken = new MappingToken(undefined, undefined, undefined);
|
||||
containerToken.add(stringToToken("image"), stringToToken("node:18"));
|
||||
|
||||
const workflowContext = {
|
||||
job: {container: containerToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const container = context.get("container");
|
||||
|
||||
expect(container).toBeDefined();
|
||||
if (!container) return;
|
||||
expect(isDescriptionDictionary(container)).toBe(true);
|
||||
|
||||
const containerDict = container as DescriptionDictionary;
|
||||
expect(containerDict.get("id")).toBeDefined();
|
||||
expect(containerDict.get("network")).toBeDefined();
|
||||
expect(containerDict.get("ports")).toBeUndefined(); // job container has no ports
|
||||
});
|
||||
|
||||
it("container has descriptions", () => {
|
||||
const containerToken = new MappingToken(undefined, undefined, undefined);
|
||||
containerToken.add(stringToToken("image"), stringToToken("node:18"));
|
||||
|
||||
const workflowContext = {
|
||||
job: {container: containerToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
const containerDescription = context.getDescription("container");
|
||||
expect(containerDescription).toBeDefined();
|
||||
|
||||
const containerDict = context.get("container") as DescriptionDictionary;
|
||||
expect(containerDict.getDescription("id")).toBeDefined();
|
||||
expect(containerDict.getDescription("network")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("services context", () => {
|
||||
it("includes services with id, network, and ports", () => {
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const services = context.get("services");
|
||||
|
||||
expect(services).toBeDefined();
|
||||
if (!services) return;
|
||||
expect(isDescriptionDictionary(services)).toBe(true);
|
||||
|
||||
const servicesDict = services as DescriptionDictionary;
|
||||
const redis = servicesDict.get("redis");
|
||||
expect(redis).toBeDefined();
|
||||
if (!redis) return;
|
||||
expect(isDescriptionDictionary(redis)).toBe(true);
|
||||
|
||||
const redisDict = redis as DescriptionDictionary;
|
||||
expect(redisDict.get("id")).toBeDefined();
|
||||
expect(redisDict.get("network")).toBeDefined();
|
||||
expect(redisDict.get("ports")).toBeDefined(); // services have ports
|
||||
});
|
||||
|
||||
it("parses service ports in host:container format", () => {
|
||||
const portsSequence = new SequenceToken(undefined, undefined, undefined);
|
||||
portsSequence.add(stringToToken("6379:6379"));
|
||||
portsSequence.add(stringToToken("8080:80"));
|
||||
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
redisToken.add(stringToToken("ports"), portsSequence);
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const services = context.get("services") as DescriptionDictionary;
|
||||
const redis = services.get("redis") as DescriptionDictionary;
|
||||
const ports = redis.get("ports") as DescriptionDictionary;
|
||||
|
||||
// Container ports should be the keys (second part of host:container)
|
||||
expect(ports.get("6379")).toBeDefined();
|
||||
expect(ports.get("80")).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses service ports in single port format", () => {
|
||||
const portsSequence = new SequenceToken(undefined, undefined, undefined);
|
||||
portsSequence.add(stringToToken("6379"));
|
||||
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
redisToken.add(stringToToken("ports"), portsSequence);
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const services = context.get("services") as DescriptionDictionary;
|
||||
const redis = services.get("redis") as DescriptionDictionary;
|
||||
const ports = redis.get("ports") as DescriptionDictionary;
|
||||
|
||||
// Single port format uses the port as the key
|
||||
expect(ports.get("6379")).toBeDefined();
|
||||
});
|
||||
|
||||
it("services have descriptions", () => {
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
const servicesDescription = context.getDescription("services");
|
||||
expect(servicesDescription).toBeDefined();
|
||||
|
||||
const services = context.get("services") as DescriptionDictionary;
|
||||
const redis = services.get("redis") as DescriptionDictionary;
|
||||
expect(redis.getDescription("id")).toBeDefined();
|
||||
expect(redis.getDescription("network")).toBeDefined();
|
||||
expect(redis.getDescription("ports")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,11 @@ import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {isMapping, isSequence} from "@actions/workflow-parser";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getDescription} from "./descriptions.js";
|
||||
|
||||
/**
|
||||
* Returns the job context with container, services, status, and check_run_id.
|
||||
*/
|
||||
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||
const jobContext = new DescriptionDictionary();
|
||||
@@ -15,7 +19,7 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
||||
const jobContainer = job.container;
|
||||
if (jobContainer && isMapping(jobContainer)) {
|
||||
const containerContext = createContainerContext(jobContainer, false);
|
||||
jobContext.add("container", containerContext);
|
||||
jobContext.add("container", containerContext, getDescription("job", "container"));
|
||||
}
|
||||
|
||||
// Services
|
||||
@@ -29,42 +33,48 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
||||
const serviceContext = createContainerContext(service.value, true);
|
||||
servicesContext.add(service.key.toString(), serviceContext);
|
||||
}
|
||||
jobContext.add("services", servicesContext);
|
||||
jobContext.add("services", servicesContext, getDescription("job", "services"));
|
||||
}
|
||||
|
||||
// Status
|
||||
jobContext.add("status", new data.Null());
|
||||
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
|
||||
|
||||
// Check run ID
|
||||
jobContext.add("check_run_id", new data.Null());
|
||||
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
|
||||
|
||||
return jobContext;
|
||||
}
|
||||
|
||||
function createContainerContext(container: MappingToken, isServices: boolean): data.Dictionary {
|
||||
const containerContext = new data.Dictionary();
|
||||
for (const {key, value} of container) {
|
||||
if (isSequence(value)) {
|
||||
// service ports are the only thing that is part of the job context
|
||||
if (key.toString() !== "ports") {
|
||||
continue;
|
||||
}
|
||||
const ports = new data.Dictionary();
|
||||
for (const item of value) {
|
||||
// We can determine the context mapping fully only if the port is defined
|
||||
// as a mapping (i.e. <port1>:<port2>), single ports are assigned randomly
|
||||
const portParts = item.toString().split(":");
|
||||
if (isServices && portParts.length === 2) {
|
||||
ports.add(portParts[1], new data.StringData(portParts[0]));
|
||||
} else {
|
||||
// If the port isn't a mapping, just use null
|
||||
ports.add(portParts[0], new data.Null());
|
||||
function createContainerContext(container: MappingToken, isServices: boolean): DescriptionDictionary {
|
||||
const containerContext = new DescriptionDictionary();
|
||||
|
||||
// id and network are always available
|
||||
containerContext.add(
|
||||
"id",
|
||||
new data.StringData(""),
|
||||
getDescription("job", isServices ? "services.<service_id>.id" : "container.id")
|
||||
);
|
||||
containerContext.add(
|
||||
"network",
|
||||
new data.StringData(""),
|
||||
getDescription("job", isServices ? "services.<service_id>.network" : "container.network")
|
||||
);
|
||||
|
||||
// ports are only available for service containers (not job container)
|
||||
if (isServices) {
|
||||
const ports = new DescriptionDictionary();
|
||||
for (const {key, value} of container) {
|
||||
if (key.toString() === "ports" && isSequence(value)) {
|
||||
for (const item of value) {
|
||||
const portParts = item.toString().split(":");
|
||||
// The key is the container port (second part if host:container format)
|
||||
const containerPort = portParts.length === 2 ? portParts[1] : portParts[0];
|
||||
ports.add(containerPort, new data.StringData(""));
|
||||
}
|
||||
}
|
||||
containerContext.add(key.toString(), ports);
|
||||
}
|
||||
containerContext.add("ports", ports, getDescription("job", "services.<service_id>.ports"));
|
||||
}
|
||||
containerContext.add("id", new data.Null());
|
||||
containerContext.add("network", new data.Null());
|
||||
|
||||
return containerContext;
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import {data} from "@actions/expressions";
|
||||
import {Job} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {BooleanToken} from "@actions/workflow-parser/templates/tokens/boolean-token";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {NumberToken} from "@actions/workflow-parser/templates/tokens/number-token";
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getStrategyContext} from "./strategy.js";
|
||||
|
||||
function stringToToken(value: string) {
|
||||
return new StringToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function boolToToken(value: boolean) {
|
||||
return new BooleanToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function numberToToken(value: number) {
|
||||
return new NumberToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
function contextFromStrategy(strategy?: TemplateToken) {
|
||||
return {
|
||||
job: {
|
||||
strategy: strategy
|
||||
}
|
||||
} as WorkflowContext;
|
||||
}
|
||||
|
||||
describe("strategy context", () => {
|
||||
describe("no strategy defined", () => {
|
||||
it("returns defaults when job is undefined", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("returns defaults when strategy is undefined", () => {
|
||||
const job = {} as Job;
|
||||
const workflowContext = {job} as WorkflowContext;
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("returns defaults when strategy is not a mapping", () => {
|
||||
const workflowContext = contextFromStrategy(stringToToken("hello"));
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("strategy defined with partial properties", () => {
|
||||
it("uses specified fail-fast, defaults for others", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("fail-fast"), boolToToken(false));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
|
||||
it("uses specified max-parallel, defaults for others", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("max-parallel"), numberToToken(5));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(5));
|
||||
});
|
||||
|
||||
it("only has matrix defined, all strategy properties use defaults", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
const matrix = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("matrix"), matrix);
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(true));
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("strategy with all properties defined", () => {
|
||||
it("uses all specified values", () => {
|
||||
const strategy = new MappingToken(undefined, undefined, undefined);
|
||||
strategy.add(stringToToken("fail-fast"), boolToToken(false));
|
||||
strategy.add(stringToToken("max-parallel"), numberToToken(3));
|
||||
const workflowContext = contextFromStrategy(strategy);
|
||||
|
||||
const context = getStrategyContext(workflowContext);
|
||||
|
||||
expect(context.get("fail-fast")).toEqual(new data.BooleanData(false));
|
||||
// job-index and job-total are runtime values, not specified in YAML
|
||||
expect(context.get("job-index")).toEqual(new data.NumberData(0));
|
||||
expect(context.get("job-total")).toEqual(new data.NumberData(1));
|
||||
expect(context.get("max-parallel")).toEqual(new data.NumberData(3));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {isMapping, isScalar, isString} from "@actions/workflow-parser";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {scalarToData} from "../utils/scalar-to-data.js";
|
||||
|
||||
// Default strategy values when no strategy block is defined
|
||||
const DEFAULT_STRATEGY = {
|
||||
"fail-fast": new data.BooleanData(true),
|
||||
"job-index": new data.NumberData(0),
|
||||
"job-total": new data.NumberData(1),
|
||||
"max-parallel": new data.NumberData(1)
|
||||
};
|
||||
|
||||
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
|
||||
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
|
||||
|
||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||
if (!strategy || !isMapping(strategy)) {
|
||||
// No strategy defined - return defaults that match runtime behavior
|
||||
return new DescriptionDictionary(
|
||||
...keys.map(key => {
|
||||
return {key, value: DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const strategyContext = new DescriptionDictionary();
|
||||
for (const pair of strategy) {
|
||||
if (!isString(pair.key)) {
|
||||
continue;
|
||||
}
|
||||
if (!keys.includes(pair.key.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = isScalar(pair.value) ? scalarToData(pair.value) : new data.Null();
|
||||
strategyContext.add(pair.key.value, value);
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (!strategyContext.get(key)) {
|
||||
// Use default value for missing properties
|
||||
strategyContext.add(key, DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]);
|
||||
}
|
||||
}
|
||||
|
||||
return strategyContext;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {ActionInputDefinition, ActionTemplate} from "@actions/workflow-parser/actions/action-template";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
|
||||
/**
|
||||
* Context information for an action.yml file, used to provide
|
||||
* expression completion with action-specific values.
|
||||
*/
|
||||
export interface ActionContext {
|
||||
uri: string;
|
||||
|
||||
/** The converted action template */
|
||||
template: ActionTemplate | undefined;
|
||||
|
||||
/** If the context is for a position within a composite step, this will be the step */
|
||||
step?: Step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context from a converted action template and token path.
|
||||
* Similar to getWorkflowContext but for action files.
|
||||
*/
|
||||
export function getActionContext(
|
||||
uri: string,
|
||||
template: ActionTemplate | undefined,
|
||||
tokenPath: TemplateToken[]
|
||||
): ActionContext {
|
||||
const context: ActionContext = {uri, template};
|
||||
if (!template) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// Only composite actions have steps
|
||||
if (template.runs?.using !== "composite") {
|
||||
return context;
|
||||
}
|
||||
|
||||
const compositeRuns = template.runs;
|
||||
if (!compositeRuns.steps?.length) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// Find the current step from the token path
|
||||
let stepsSequence: SequenceToken | undefined;
|
||||
let stepToken: MappingToken | undefined;
|
||||
|
||||
for (const token of tokenPath) {
|
||||
const defKey = token.definition?.key;
|
||||
if (defKey === "composite-steps" && token instanceof SequenceToken) {
|
||||
stepsSequence = token;
|
||||
} else if ((defKey === "run-step" || defKey === "uses-step") && isMapping(token)) {
|
||||
stepToken = token;
|
||||
}
|
||||
}
|
||||
|
||||
if (stepsSequence && stepToken) {
|
||||
context.step = findStep(compositeRuns.steps, stepsSequence, stepToken);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Step that corresponds to the given step token.
|
||||
*/
|
||||
function findStep(steps: Step[], stepsSequence: SequenceToken, stepToken: MappingToken): Step | undefined {
|
||||
// Find the step by matching index in the sequence
|
||||
let stepIndex = -1;
|
||||
for (let i = 0; i < stepsSequence.count; i++) {
|
||||
if (stepsSequence.get(i) === stepToken) {
|
||||
stepIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stepIndex === -1 || stepIndex >= steps.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return steps[stepIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get input definitions from the action template.
|
||||
*/
|
||||
export function getActionInputs(template: ActionTemplate | undefined): ActionInputDefinition[] {
|
||||
return template?.inputs ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get step IDs from composite action steps that appear before the current step.
|
||||
* This is used for `steps.<id>` context completion - you can only reference
|
||||
* steps that have already run.
|
||||
*/
|
||||
export function getActionStepIdsBefore(context: ActionContext): string[] {
|
||||
const template = context.template;
|
||||
if (!template || template.runs?.using !== "composite") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const compositeRuns = template.runs;
|
||||
const steps = compositeRuns.steps ?? [];
|
||||
const currentStep = context.step;
|
||||
|
||||
const stepIds: string[] = [];
|
||||
for (const step of steps) {
|
||||
// Stop when we reach the current step
|
||||
if (currentStep && step === currentStep) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Only include steps with explicit IDs
|
||||
if (step.id) {
|
||||
stepIds.push(step.id);
|
||||
}
|
||||
}
|
||||
|
||||
return stepIds;
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
|
||||
/**
|
||||
* Represents the contextual position within a workflow file.
|
||||
* Used to determine which expression contexts are available at a given location.
|
||||
*/
|
||||
export interface WorkflowContext {
|
||||
uri: string;
|
||||
|
||||
@@ -21,6 +25,12 @@ export interface WorkflowContext {
|
||||
step?: Step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a WorkflowContext by walking the token path to identify the current job and step.
|
||||
* @param uri - The URI of the workflow file
|
||||
* @param template - The parsed workflow template
|
||||
* @param tokenPath - The path of tokens from root to the current position
|
||||
*/
|
||||
export function getWorkflowContext(
|
||||
uri: string,
|
||||
template: WorkflowTemplate | undefined,
|
||||
@@ -73,6 +83,10 @@ export function getWorkflowContext(
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a Step by matching the step token's position in the steps sequence.
|
||||
* Steps may not have IDs, so we locate them by index rather than by identifier.
|
||||
*/
|
||||
function findStep(steps?: Step[], stepSequence?: SequenceToken, stepToken?: MappingToken): Step | undefined {
|
||||
if (!steps || !stepSequence || !stepToken) {
|
||||
return undefined;
|
||||
|
||||
@@ -3,6 +3,9 @@ import {DESCRIPTION} from "@actions/workflow-parser/templates/template-constants
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {TokenResult} from "../utils/find-token.js";
|
||||
|
||||
/**
|
||||
* Checks if the token is an input value in a reusable workflow job's `with:` block.
|
||||
*/
|
||||
export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
|
||||
return (
|
||||
tokenResult.parent?.definition?.key === "workflow-job-with" &&
|
||||
@@ -11,6 +14,11 @@ export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the description of an input from a called reusable workflow.
|
||||
* When a workflow calls another workflow with `uses:`, this fetches the input's
|
||||
* description from the called workflow's `workflow_call.inputs` definitions.
|
||||
*/
|
||||
export function getReusableWorkflowInputDescription(
|
||||
workflowContext: WorkflowContext,
|
||||
tokenResult: TokenResult
|
||||
|
||||
@@ -129,4 +129,31 @@ jobs:
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("links for actions in composite action", async () => {
|
||||
const input = `name: My Composite Action
|
||||
description: A composite action with nested actions
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- run: echo "Hello"
|
||||
shell: bash`;
|
||||
const result = await documentLinks(createDocument("action.yml", input), undefined);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].target).toBe("https://www.github.com/actions/checkout/tree/v4/");
|
||||
expect(result[0].tooltip).toBe("Open action on GitHub");
|
||||
expect(result[1].target).toBe("https://www.github.com/actions/setup-node/tree/v4/");
|
||||
});
|
||||
|
||||
it("no links for non-composite action", async () => {
|
||||
const input = `name: My Node Action
|
||||
description: A node action
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`;
|
||||
const result = await documentLinks(createDocument("action.yml", input), undefined);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,29 +6,82 @@ import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {DocumentLink} from "vscode-languageserver-types";
|
||||
import * as vscodeURI from "vscode-uri";
|
||||
import {actionUrl, parseActionReference} from "./action.js";
|
||||
import {isActionDocument} from "./utils/document-type.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {
|
||||
getOrConvertActionTemplate,
|
||||
getOrConvertWorkflowTemplate,
|
||||
getOrParseAction,
|
||||
getOrParseWorkflow
|
||||
} from "./utils/workflow-cache.js";
|
||||
|
||||
/**
|
||||
* Generates clickable links for action references and reusable workflows.
|
||||
*/
|
||||
export async function documentLinks(document: TextDocument, workspace: string | undefined): Promise<DocumentLink[]> {
|
||||
const file: File = {
|
||||
name: document.uri,
|
||||
content: document.getText()
|
||||
};
|
||||
|
||||
const parsedWorkflow = fetchOrParseWorkflow(file, document.uri);
|
||||
return isActionDocument(document.uri)
|
||||
? actionDocumentLinks(file, document.uri)
|
||||
: workflowDocumentLinks(file, document.uri, workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates clickable links for action references in action.yml files.
|
||||
*/
|
||||
function actionDocumentLinks(file: File, uri: string): DocumentLink[] {
|
||||
const parsedAction = getOrParseAction(file, uri);
|
||||
if (!parsedAction?.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = getOrConvertActionTemplate(parsedAction.context, parsedAction.value, uri, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
const links: DocumentLink[] = [];
|
||||
|
||||
// Only composite actions have steps
|
||||
if (template?.runs?.using !== "composite") {
|
||||
return links;
|
||||
}
|
||||
|
||||
const steps = template.runs.steps ?? [];
|
||||
for (const step of steps) {
|
||||
if ("uses" in step) {
|
||||
const actionRef = parseActionReference(step.uses.value);
|
||||
if (!actionRef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = actionUrl(actionRef);
|
||||
|
||||
links.push({
|
||||
range: mapRange(step.uses.range),
|
||||
target: url,
|
||||
tooltip: `Open action on GitHub`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates clickable links for action references and reusable workflows in workflow files.
|
||||
*/
|
||||
async function workflowDocumentLinks(file: File, uri: string, workspace: string | undefined): Promise<DocumentLink[]> {
|
||||
const parsedWorkflow = getOrParseWorkflow(file, uri);
|
||||
if (!parsedWorkflow?.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = await fetchOrConvertWorkflowTemplate(
|
||||
parsedWorkflow.context,
|
||||
parsedWorkflow.value,
|
||||
document.uri,
|
||||
undefined,
|
||||
{
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
}
|
||||
);
|
||||
const template = await getOrConvertWorkflowTemplate(parsedWorkflow.context, parsedWorkflow.value, uri, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
const links: DocumentLink[] = [];
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ describe("end-to-end", () => {
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(13);
|
||||
const labelsWithDetails = result.map(x => (x.detail ? `${x.label} (${x.detail})` : x.label));
|
||||
const labelsWithDetails = result.map(x =>
|
||||
x.labelDetails?.description ? `${x.label} (${x.labelDetails.description})` : x.label
|
||||
);
|
||||
expect(labelsWithDetails).toEqual([
|
||||
"concurrency",
|
||||
"concurrency (full syntax)",
|
||||
|
||||
@@ -3,7 +3,7 @@ import {convertWorkflowTemplate, parseWorkflow} from "@actions/workflow-parser";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {ContextProviderConfig} from "../context-providers/config.js";
|
||||
import {getContext, Mode} from "../context-providers/default.js";
|
||||
import {getWorkflowExpressionContext, Mode} from "../context-providers/default.js";
|
||||
import {getWorkflowContext} from "../context/workflow-context.js";
|
||||
import {validatorFunctions} from "../expression-validation/functions.js";
|
||||
import {nullTrace} from "../nulltrace.js";
|
||||
@@ -116,7 +116,12 @@ async function hoverExpression(input: string) {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
const workflowContext = getWorkflowContext(td.uri, template, []);
|
||||
const context = await getContext(allowedContext, contextProviderConfig, workflowContext, Mode.Completion);
|
||||
const context = await getWorkflowExpressionContext(
|
||||
allowedContext,
|
||||
contextProviderConfig,
|
||||
workflowContext,
|
||||
Mode.Completion
|
||||
);
|
||||
|
||||
const l = new Lexer(td.getText());
|
||||
const lr = l.lex();
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {hover} from "./hover";
|
||||
|
||||
describe("hover action files", () => {
|
||||
function createActionDocument(
|
||||
content: string,
|
||||
uri = "file:///test/action.yml"
|
||||
): [TextDocument, {line: number; character: number}] {
|
||||
// Parse cursor position and remove the | character
|
||||
const cursorIndex = content.indexOf("|");
|
||||
if (cursorIndex === -1) {
|
||||
throw new Error("No cursor (|) found in content");
|
||||
}
|
||||
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
|
||||
const doc = TextDocument.create(uri, "yaml", 1, newContent);
|
||||
const position = doc.positionAt(cursorIndex);
|
||||
return [doc, position];
|
||||
}
|
||||
|
||||
describe("top-level keys", () => {
|
||||
it("shows description for name key", async () => {
|
||||
const [doc, position] = createActionDocument(`na|me: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("name");
|
||||
});
|
||||
|
||||
it("shows description for description key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
descrip|tion: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("description");
|
||||
});
|
||||
|
||||
it("shows description for runs key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
ru|ns:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
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", () => {
|
||||
it("shows description for using key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
us|ing: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("runtime");
|
||||
});
|
||||
|
||||
it("shows description for main key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
ma|in: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inputs", () => {
|
||||
it("shows description for inputs section", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
inp|uts:
|
||||
my-input:
|
||||
description: A test input
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("input");
|
||||
});
|
||||
|
||||
it("shows description for required key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
inputs:
|
||||
my-input:
|
||||
description: A test input
|
||||
requ|ired: true
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("required");
|
||||
});
|
||||
|
||||
it("shows allowed context for default value", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
inputs:
|
||||
my-input:
|
||||
description: A test input
|
||||
def|ault: foo
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// Input defaults can use expressions with github, strategy, matrix, job, runner contexts
|
||||
expect(result?.contents).toContain("github");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding", () => {
|
||||
it("shows description for branding section", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
brand|ing:
|
||||
icon: activity
|
||||
color: blue`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("brand");
|
||||
expect(result?.contents).toContain("Documentation");
|
||||
});
|
||||
|
||||
it("shows description for icon key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
branding:
|
||||
ic|on: activity
|
||||
color: blue`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("icon");
|
||||
});
|
||||
});
|
||||
|
||||
describe("document type routing", () => {
|
||||
it("routes action.yml to action hover", async () => {
|
||||
const [doc, position] = createActionDocument(
|
||||
`na|me: My Action
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`,
|
||||
"file:///my-repo/action.yml"
|
||||
);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not route workflow files to action hover", async () => {
|
||||
const doc = TextDocument.create(
|
||||
"file:///repo/.github/workflows/ci.yml",
|
||||
"yaml",
|
||||
1,
|
||||
`name: CI
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hello`
|
||||
);
|
||||
// Hovering over 'name' in a workflow file should give workflow-specific info
|
||||
const result = await hover(doc, {line: 0, character: 2});
|
||||
|
||||
// The workflow hover might not have description for workflow name,
|
||||
// but it should not crash
|
||||
expect(result === null || result.contents !== undefined).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+114
-58
@@ -1,6 +1,7 @@
|
||||
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 {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";
|
||||
@@ -10,8 +11,9 @@ import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {Hover} from "vscode-languageserver-types";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
import {getContext, Mode} from "./context-providers/default.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 {
|
||||
getReusableWorkflowInputDescription,
|
||||
@@ -20,10 +22,12 @@ import {
|
||||
import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-pos.js";
|
||||
import {HoverVisitor} from "./expression-hover/visitor.js";
|
||||
import {info} from "./log.js";
|
||||
import {nullTrace} from "./nulltrace.js";
|
||||
import {isActionDocument} from "./utils/document-type.js";
|
||||
import {isPotentiallyExpression} from "./utils/expression-detection.js";
|
||||
import {findToken} from "./utils/find-token.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {getOrConvertActionTemplate, getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
|
||||
export type HoverConfig = {
|
||||
descriptionProvider?: DescriptionProvider;
|
||||
@@ -32,79 +36,125 @@ export type HoverConfig = {
|
||||
};
|
||||
|
||||
export type DescriptionProvider = {
|
||||
getDescription(context: WorkflowContext, token: TemplateToken, path: TemplateToken[]): Promise<string | undefined>;
|
||||
getDescription(
|
||||
context: WorkflowContext | ActionContext,
|
||||
token: TemplateToken,
|
||||
path: TemplateToken[]
|
||||
): Promise<string | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns hover information for the token at the given position.
|
||||
*/
|
||||
export async function hover(document: TextDocument, position: Position, config?: HoverConfig): Promise<Hover | null> {
|
||||
const file: File = {
|
||||
name: document.uri,
|
||||
content: document.getText()
|
||||
};
|
||||
|
||||
const parsedWorkflow = fetchOrParseWorkflow(file, document.uri);
|
||||
if (!parsedWorkflow?.value) {
|
||||
// Determine document type based on file path (action.yml vs workflow file)
|
||||
const isAction = isActionDocument(document.uri);
|
||||
|
||||
// Parse document
|
||||
const parsedTemplate = isAction ? parseAction(file, nullTrace) : getOrParseWorkflow(file, document.uri);
|
||||
if (!parsedTemplate?.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const template = await fetchOrConvertWorkflowTemplate(
|
||||
parsedWorkflow.context,
|
||||
parsedWorkflow.value,
|
||||
document.uri,
|
||||
config,
|
||||
{
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
|
||||
}
|
||||
);
|
||||
|
||||
const tokenResult = findToken(position, parsedWorkflow.value);
|
||||
// Find the token at the cursor position
|
||||
const tokenResult = findToken(position, parsedTemplate.value);
|
||||
const {token, keyToken, parent} = tokenResult;
|
||||
const tokenDefinitionInfo = (keyToken || parent || token)?.definitionInfo;
|
||||
|
||||
const workflowContext = getWorkflowContext(document.uri, template, tokenResult.path);
|
||||
if (token && tokenDefinitionInfo) {
|
||||
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
|
||||
info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`);
|
||||
|
||||
const allowedContext = tokenDefinitionInfo.allowedContext || [];
|
||||
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||
const context = await getContext(namedContexts, config?.contextProviderConfig, workflowContext, Mode.Completion);
|
||||
|
||||
for (const func of functions) {
|
||||
func.description = getFunctionDescription(func.name);
|
||||
}
|
||||
|
||||
const exprPos = mapToExpressionPos(token, position);
|
||||
if (exprPos) {
|
||||
return expressionHover(exprPos, context, namedContexts, functions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!token?.definition) {
|
||||
// Early exit if there's nothing to provide hover for
|
||||
const hoverToken = token || keyToken;
|
||||
const isExpressionHover =
|
||||
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token));
|
||||
if (!isExpressionHover && !hoverToken?.definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
info(`Calculating hover for token with definition ${token.definition.key}`);
|
||||
// Build document context (jobs, steps, inputs, etc.) from the parsed template
|
||||
const documentContext = isAction
|
||||
? getActionContext(
|
||||
document.uri,
|
||||
getOrConvertActionTemplate(parsedTemplate.context, parsedTemplate.value, document.uri, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
}),
|
||||
tokenResult.path
|
||||
)
|
||||
: getWorkflowContext(
|
||||
document.uri,
|
||||
await getOrConvertWorkflowTemplate(parsedTemplate.context, parsedTemplate.value, document.uri, config, {
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0
|
||||
}),
|
||||
tokenResult.path
|
||||
);
|
||||
|
||||
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
||||
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
|
||||
description = appendContext(description, token.definitionInfo?.allowedContext);
|
||||
return {
|
||||
contents: description,
|
||||
range: mapRange(token.range)
|
||||
} satisfies Hover;
|
||||
// Expression hover
|
||||
if (isExpressionHover) {
|
||||
info(`Calculating expression hover for token with definition ${tokenDefinitionInfo.definition.key}`);
|
||||
|
||||
const allowedContext = tokenDefinitionInfo.allowedContext || [];
|
||||
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||
|
||||
// Build expression context with named contexts (github, env, etc.) and their descriptions
|
||||
const expressionContext = isAction
|
||||
? getActionExpressionContext(
|
||||
namedContexts,
|
||||
config?.contextProviderConfig,
|
||||
documentContext as ActionContext,
|
||||
Mode.Hover
|
||||
)
|
||||
: await getWorkflowExpressionContext(
|
||||
namedContexts,
|
||||
config?.contextProviderConfig,
|
||||
documentContext as WorkflowContext,
|
||||
Mode.Hover
|
||||
);
|
||||
|
||||
// Populate function descriptions for hover display
|
||||
for (const func of functions) {
|
||||
func.description = getFunctionDescription(func.name);
|
||||
}
|
||||
|
||||
// Convert document position to expression-relative position
|
||||
const exprPos = mapToExpressionPos(token, position);
|
||||
if (exprPos) {
|
||||
// Find the expression element at the cursor and return its description
|
||||
return expressionHover(exprPos, expressionContext, namedContexts, functions);
|
||||
}
|
||||
}
|
||||
|
||||
let description = await getDescription(config, workflowContext, token, tokenResult.path);
|
||||
description = appendContext(description, token.definitionInfo?.allowedContext);
|
||||
if (!hoverToken?.definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Non-expression hover: show the schema description for the YAML key or value
|
||||
info(`Calculating hover for token with definition ${hoverToken.definition.key}`);
|
||||
|
||||
let description: string;
|
||||
if (!isAction && tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
|
||||
// Reusable workflow call: fetch the called workflow's input descriptions
|
||||
description = getReusableWorkflowInputDescription(documentContext as WorkflowContext, tokenResult);
|
||||
} else {
|
||||
// Default: use custom provider or token's schema description
|
||||
description =
|
||||
(await getDescription(config, documentContext, hoverToken, tokenResult.path)) || hoverToken.description || "";
|
||||
}
|
||||
|
||||
// Return hover with description and available expression contexts
|
||||
return {
|
||||
contents: description,
|
||||
range: mapRange(token.range)
|
||||
contents: appendContext(description, hoverToken.definitionInfo?.allowedContext),
|
||||
range: mapRange(hoverToken.range)
|
||||
} satisfies Hover;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends available expression contexts and functions to a hover description.
|
||||
* For example: "Available expression contexts: `github`, `env`"
|
||||
*/
|
||||
function appendContext(description: string, allowedContext?: string[]) {
|
||||
if (!allowedContext || allowedContext.length == 0) {
|
||||
return description;
|
||||
@@ -128,24 +178,30 @@ function appendContext(description: string, allowedContext?: string[]) {
|
||||
return `${description}${namedContextsString}${functionsString}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a custom description from the configured description provider.
|
||||
* Used to fetch rich descriptions like action input docs from GitHub repos.
|
||||
*/
|
||||
async function getDescription(
|
||||
config: HoverConfig | undefined,
|
||||
workflowContext: WorkflowContext,
|
||||
documentContext: WorkflowContext | ActionContext,
|
||||
token: TemplateToken,
|
||||
path: TemplateToken[]
|
||||
) {
|
||||
const defaultDescription = token.description || "";
|
||||
): Promise<string | undefined> {
|
||||
if (!config?.descriptionProvider) {
|
||||
return defaultDescription;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const description = await config.descriptionProvider.getDescription(workflowContext, token, path);
|
||||
return description || defaultDescription;
|
||||
return await config.descriptionProvider.getDescription(documentContext, token, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an expression and finds the element at the cursor position to show its description.
|
||||
* For example, hovering over `github.actor` shows "The login of the user that triggered the workflow".
|
||||
*/
|
||||
function expressionHover(
|
||||
exprPos: ExpressionPos,
|
||||
context: DescriptionDictionary,
|
||||
expressionContext: DescriptionDictionary,
|
||||
namedContexts: string[],
|
||||
functions: FunctionInfo[]
|
||||
): Hover | null {
|
||||
@@ -165,7 +221,7 @@ function expressionHover(
|
||||
call: () => new data.Null()
|
||||
});
|
||||
}
|
||||
const hv = new HoverVisitor(position, context, functionMap);
|
||||
const hv = new HoverVisitor(position, expressionContext, functionMap);
|
||||
const hoverResult = hv.hover(expr);
|
||||
if (!hoverResult) {
|
||||
return null;
|
||||
|
||||
@@ -4,7 +4,8 @@ import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {InlayHint, InlayHintKind} from "vscode-languageserver-types";
|
||||
import {fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {isActionDocument} from "./utils/document-type.js";
|
||||
import {getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
|
||||
/**
|
||||
* Returns inlay hints for a workflow document.
|
||||
@@ -15,12 +16,17 @@ import {fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
* @returns Array of inlay hints
|
||||
*/
|
||||
export function getInlayHints(document: TextDocument): InlayHint[] {
|
||||
// Inlay hints are only supported for workflow files (cron expressions)
|
||||
if (isActionDocument(document.uri)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const file: File = {
|
||||
name: document.uri,
|
||||
content: document.getText()
|
||||
};
|
||||
|
||||
const result = fetchOrParseWorkflow(file, document.uri);
|
||||
const result = getOrParseWorkflow(file, document.uri);
|
||||
if (!result?.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import {detectDocumentType, isActionDocument, isWorkflowDocument} from "./document-type";
|
||||
|
||||
describe("detectDocumentType", () => {
|
||||
describe("action files", () => {
|
||||
it("detects action.yml", () => {
|
||||
expect(detectDocumentType("/path/to/action.yml")).toBe("action");
|
||||
});
|
||||
|
||||
it("detects action.yaml", () => {
|
||||
expect(detectDocumentType("/path/to/action.yaml")).toBe("action");
|
||||
});
|
||||
|
||||
it("detects action.yml with case insensitivity", () => {
|
||||
expect(detectDocumentType("/path/to/ACTION.YML")).toBe("action");
|
||||
expect(detectDocumentType("/path/to/Action.Yaml")).toBe("action");
|
||||
});
|
||||
|
||||
it("detects nested action.yml", () => {
|
||||
expect(detectDocumentType("/repo/.github/actions/my-action/action.yml")).toBe("action");
|
||||
});
|
||||
|
||||
it("detects bare action.yml", () => {
|
||||
expect(detectDocumentType("action.yml")).toBe("action");
|
||||
});
|
||||
|
||||
it("handles Windows paths", () => {
|
||||
expect(detectDocumentType("C:\\Users\\me\\action.yml")).toBe("action");
|
||||
expect(detectDocumentType("C:\\repo\\.github\\actions\\my-action\\action.yml")).toBe("action");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow files", () => {
|
||||
it("detects workflow files in .github/workflows", () => {
|
||||
expect(detectDocumentType("/repo/.github/workflows/ci.yml")).toBe("workflow");
|
||||
expect(detectDocumentType("/repo/.github/workflows/build.yaml")).toBe("workflow");
|
||||
});
|
||||
|
||||
it("detects workflow files in .github/workflows-lab", () => {
|
||||
expect(detectDocumentType("/repo/.github/workflows-lab/ci.yml")).toBe("workflow");
|
||||
expect(detectDocumentType("/repo/.github/workflows-lab/build.yaml")).toBe("workflow");
|
||||
});
|
||||
|
||||
it("detects workflow files case insensitively", () => {
|
||||
expect(detectDocumentType("/repo/.github/workflows/CI.YML")).toBe("workflow");
|
||||
});
|
||||
|
||||
it("handles Windows paths for workflows", () => {
|
||||
expect(detectDocumentType("C:\\repo\\.github\\workflows\\ci.yml")).toBe("workflow");
|
||||
expect(detectDocumentType("C:\\repo\\.github\\workflows-lab\\ci.yml")).toBe("workflow");
|
||||
});
|
||||
|
||||
it("workflow path takes precedence over action filename", () => {
|
||||
// Edge case: action.yml inside .github/workflows should be treated as workflow
|
||||
expect(detectDocumentType("/repo/.github/workflows/action.yml")).toBe("workflow");
|
||||
expect(detectDocumentType("/repo/.github/workflows/action.yaml")).toBe("workflow");
|
||||
expect(detectDocumentType("/repo/.github/workflows-lab/action.yml")).toBe("workflow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown files", () => {
|
||||
it("returns unknown for other yaml files", () => {
|
||||
expect(detectDocumentType("/path/to/config.yml")).toBe("unknown");
|
||||
expect(detectDocumentType("/path/to/docker-compose.yaml")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns unknown for non-yaml files", () => {
|
||||
expect(detectDocumentType("/path/to/file.txt")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isActionDocument", () => {
|
||||
it("returns true for action files", () => {
|
||||
expect(isActionDocument("/path/to/action.yml")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for workflow files", () => {
|
||||
expect(isActionDocument("/repo/.github/workflows/ci.yml")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unknown files", () => {
|
||||
expect(isActionDocument("/path/to/config.yml")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkflowDocument", () => {
|
||||
it("returns true for workflow files", () => {
|
||||
expect(isWorkflowDocument("/repo/.github/workflows/ci.yml")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for action files", () => {
|
||||
expect(isWorkflowDocument("/path/to/action.yml")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unknown files", () => {
|
||||
expect(isWorkflowDocument("/path/to/config.yml")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Document type detection for workflow and action files.
|
||||
* Detection is based on file path/name only - content heuristics are not used
|
||||
* because files in non-standard locations wouldn't work as workflows/actions anyway.
|
||||
*/
|
||||
|
||||
export type DocumentType = "workflow" | "action" | "unknown";
|
||||
|
||||
/**
|
||||
* Detects whether a document is a workflow file, action file, or unknown based on its URI.
|
||||
*
|
||||
* @param uri The document URI or file path
|
||||
* @returns The detected document type
|
||||
*/
|
||||
export function detectDocumentType(uri: string): DocumentType {
|
||||
// Normalize path separators
|
||||
const normalizedUri = uri.replace(/\\/g, "/");
|
||||
|
||||
// Check for workflow file patterns FIRST (more specific path takes precedence)
|
||||
// Matches: .github/workflows/*.yml or .github/workflows/*.yaml
|
||||
// Also matches: .github/workflows-lab/*.yml or .github/workflows-lab/*.yaml
|
||||
// This ensures .github/workflows/action.yml is treated as a workflow, not an action
|
||||
if (/\.github\/workflows(-lab)?\/[^/]+\.ya?ml$/i.test(normalizedUri)) {
|
||||
return "workflow";
|
||||
}
|
||||
|
||||
// Check for action.yml/action.yaml patterns
|
||||
// Matches: action.yml, action.yaml, .github/actions/my-action/action.yml, etc.
|
||||
if (/\/action\.ya?ml$/i.test(normalizedUri) || /^action\.ya?ml$/i.test(normalizedUri)) {
|
||||
return "action";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a document is an action file
|
||||
*/
|
||||
export function isActionDocument(uri: string): boolean {
|
||||
return detectDocumentType(uri) === "action";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a document is a workflow file
|
||||
*/
|
||||
export function isWorkflowDocument(uri: string): boolean {
|
||||
return detectDocumentType(uri) === "workflow";
|
||||
}
|
||||
@@ -6,8 +6,24 @@ import {Range} from "vscode-languageserver-types";
|
||||
|
||||
const PLACEHOLDER_KEY = "key";
|
||||
|
||||
// Transform a document to work around YAML parsing issues
|
||||
// Based on `_transform` in https://github.com/cschleiden/github-actions-parser/blob/main/src/lib/parser/complete.ts#L311
|
||||
/**
|
||||
* Transforms a document to make it valid YAML so the parser can understand
|
||||
* the cursor position during auto-completion.
|
||||
*
|
||||
* When typing in an IDE, the document is usually invalid YAML:
|
||||
* - `runs-on` without `:` isn't a valid key
|
||||
* - Empty lines don't parse as anything
|
||||
* - `- ` without a value isn't complete
|
||||
*
|
||||
* This function inserts placeholders to make the document parseable:
|
||||
* - Empty line → inserts `key:` placeholder
|
||||
* - Line without colon → appends `:`
|
||||
* - Sequence item `- ` → inserts `key` after the dash
|
||||
*
|
||||
* Lines containing `${{` are skipped to avoid breaking multi-line strings.
|
||||
*
|
||||
* The `isPlaceholder()` helper filters out the fake entries from completions.
|
||||
*/
|
||||
export function transform(doc: TextDocument, pos: Position): [TextDocument, Position] {
|
||||
let offset = doc.offsetAt(pos);
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import {convertWorkflowTemplate, parseWorkflow, ParseWorkflowResult, WorkflowTemplate} from "@actions/workflow-parser";
|
||||
import {convertWorkflowTemplate, parseWorkflow, TemplateParseResult, WorkflowTemplate} from "@actions/workflow-parser";
|
||||
import {parseAction} from "@actions/workflow-parser/actions/action-parser";
|
||||
import {
|
||||
ActionTemplate,
|
||||
ActionTemplateConverterOptions,
|
||||
convertActionTemplate
|
||||
} from "@actions/workflow-parser/actions/action-template";
|
||||
import {WorkflowTemplateConverterOptions} from "@actions/workflow-parser/model/convert";
|
||||
import {TemplateContext} from "@actions/workflow-parser/templates/template-context";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
@@ -7,28 +13,36 @@ import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {CompletionConfig} from "../complete.js";
|
||||
import {nullTrace} from "../nulltrace.js";
|
||||
|
||||
const parsedWorkflowCache = new Map<string, ParseWorkflowResult>();
|
||||
const parsedWorkflowCache = new Map<string, TemplateParseResult>();
|
||||
const parsedActionCache = new Map<string, TemplateParseResult>();
|
||||
const workflowTemplateCache = new Map<string, WorkflowTemplate>();
|
||||
const actionTemplateCache = new Map<string, ActionTemplate>();
|
||||
|
||||
export function clearCacheEntry(uri: string) {
|
||||
parsedWorkflowCache.delete(uri);
|
||||
parsedWorkflowCache.delete(workflowKey(uri, true));
|
||||
parsedWorkflowCache.delete(cacheKey(uri, true));
|
||||
parsedActionCache.delete(uri);
|
||||
parsedActionCache.delete(cacheKey(uri, true));
|
||||
workflowTemplateCache.delete(uri);
|
||||
workflowTemplateCache.delete(workflowKey(uri, true));
|
||||
workflowTemplateCache.delete(cacheKey(uri, true));
|
||||
actionTemplateCache.delete(uri);
|
||||
actionTemplateCache.delete(cacheKey(uri, true));
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
parsedWorkflowCache.clear();
|
||||
parsedActionCache.clear();
|
||||
workflowTemplateCache.clear();
|
||||
actionTemplateCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a workflow file and caches the result
|
||||
* Parses a workflow file, returning cached result if available
|
||||
* @param transformed Indicates whether the workflow has been transformed before parsing
|
||||
* @returns the {@link ParseWorkflowResult}
|
||||
* @returns the {@link TemplateParseResult}
|
||||
*/
|
||||
export function fetchOrParseWorkflow(file: File, uri: string, transformed = false): ParseWorkflowResult {
|
||||
const key = workflowKey(uri, transformed);
|
||||
export function getOrParseWorkflow(file: File, uri: string, transformed = false): TemplateParseResult {
|
||||
const key = cacheKey(uri, transformed);
|
||||
const cachedResult = parsedWorkflowCache.get(key);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
@@ -39,11 +53,27 @@ export function fetchOrParseWorkflow(file: File, uri: string, transformed = fals
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a workflow template and caches the result
|
||||
* Parses an action file, returning cached result if available
|
||||
* @param transformed Indicates whether the action has been transformed before parsing
|
||||
* @returns the {@link TemplateParseResult}
|
||||
*/
|
||||
export function getOrParseAction(file: File, uri: string, transformed = false): TemplateParseResult {
|
||||
const key = cacheKey(uri, transformed);
|
||||
const cachedResult = parsedActionCache.get(key);
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
const result = parseAction(file, nullTrace);
|
||||
parsedActionCache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a workflow template, returning cached result if available
|
||||
* @param transformed Indicates whether the workflow has been transformed before parsing
|
||||
* @returns the converted {@link WorkflowTemplate}
|
||||
*/
|
||||
export async function fetchOrConvertWorkflowTemplate(
|
||||
export async function getOrConvertWorkflowTemplate(
|
||||
context: TemplateContext,
|
||||
template: TemplateToken,
|
||||
uri: string,
|
||||
@@ -51,7 +81,7 @@ export async function fetchOrConvertWorkflowTemplate(
|
||||
options?: WorkflowTemplateConverterOptions,
|
||||
transformed = false
|
||||
): Promise<WorkflowTemplate> {
|
||||
const key = workflowKey(uri, transformed);
|
||||
const key = cacheKey(uri, transformed);
|
||||
const cachedTemplate = workflowTemplateCache.get(key);
|
||||
if (cachedTemplate) {
|
||||
return cachedTemplate;
|
||||
@@ -61,8 +91,30 @@ export async function fetchOrConvertWorkflowTemplate(
|
||||
return workflowTemplate;
|
||||
}
|
||||
|
||||
// Use a separate cache key for transformed workflows
|
||||
function workflowKey(uri: string, transformed: boolean): string {
|
||||
/**
|
||||
* Converts an action template, returning cached result if available
|
||||
* @param transformed Indicates whether the action has been transformed before parsing
|
||||
* @returns the converted {@link ActionTemplate}
|
||||
*/
|
||||
export function getOrConvertActionTemplate(
|
||||
context: TemplateContext,
|
||||
template: TemplateToken,
|
||||
uri: string,
|
||||
options?: ActionTemplateConverterOptions,
|
||||
transformed = false
|
||||
): ActionTemplate {
|
||||
const key = cacheKey(uri, transformed);
|
||||
const cachedTemplate = actionTemplateCache.get(key);
|
||||
if (cachedTemplate) {
|
||||
return cachedTemplate;
|
||||
}
|
||||
const actionTemplate = convertActionTemplate(context, template, options);
|
||||
actionTemplateCache.set(key, actionTemplate);
|
||||
return actionTemplate;
|
||||
}
|
||||
|
||||
// Use a separate cache key for transformed documents
|
||||
function cacheKey(uri: string, transformed: boolean): string {
|
||||
if (transformed) {
|
||||
return `transformed-${uri}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
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 {mapRange} from "./utils/range.js";
|
||||
import {ValidationConfig} from "./validate.js";
|
||||
|
||||
/**
|
||||
* Validates action references in workflow steps, checking for valid inputs and required inputs.
|
||||
*/
|
||||
export async function validateActionReference(
|
||||
diagnostics: Diagnostic[],
|
||||
stepToken: TemplateToken,
|
||||
step: Step | undefined,
|
||||
config: ValidationConfig | undefined
|
||||
): Promise<void> {
|
||||
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.actionsMetadataProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the action reference (e.g., "actions/checkout@v4" -> {owner, name, ref})
|
||||
const action = parseActionReference(step.uses.value);
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the action's metadata (action.yml) to get input definitions
|
||||
const actionMetadata = await config.actionsMetadataProvider.fetchActionMetadata(action);
|
||||
if (actionMetadata === undefined) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(step.uses.range),
|
||||
message: `Unable to resolve action \`${step.uses.value}\`, repository or version not found`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the "with" key in the step token to get the inputs passed to the action
|
||||
let withKey: ScalarToken | undefined;
|
||||
let withToken: TemplateToken | undefined;
|
||||
for (const {key, value} of stepToken) {
|
||||
if (key.toString() === "with") {
|
||||
withKey = key;
|
||||
withToken = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect the inputs provided in the step's "with" block
|
||||
const stepInputs = new Map<string, ScalarToken>();
|
||||
if (withToken && isMapping(withToken)) {
|
||||
for (const {key} of withToken) {
|
||||
stepInputs.set(key.toString(), key);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip validation if the action doesn't define any inputs
|
||||
const actionInputs = actionMetadata.inputs;
|
||||
if (actionInputs === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check each provided input is valid and not deprecated
|
||||
for (const [input, inputToken] of stepInputs) {
|
||||
if (!actionInputs[input]) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(inputToken.range),
|
||||
message: `Invalid action input '${input}'`
|
||||
});
|
||||
}
|
||||
|
||||
const deprecationMessage = actionInputs[input]?.deprecationMessage;
|
||||
if (deprecationMessage) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: mapRange(inputToken.range),
|
||||
message: deprecationMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for required inputs that weren't provided and don't have defaults
|
||||
const missingRequiredInputs = Object.entries(actionInputs).filter(
|
||||
([inputName, input]) => input.required && !stepInputs.has(inputName) && input.default === undefined
|
||||
);
|
||||
|
||||
// Report missing required inputs
|
||||
if (missingRequiredInputs.length > 0) {
|
||||
const message =
|
||||
missingRequiredInputs.length === 1
|
||||
? `Missing required input \`${missingRequiredInputs[0][0]}\``
|
||||
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
|
||||
message: message
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {validate} from "./validate";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
|
||||
describe("validate action files", () => {
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
function createActionDocument(content: string, uri = "file:///test/action.yml"): TextDocument {
|
||||
return TextDocument.create(uri, "yaml", 1, content);
|
||||
}
|
||||
|
||||
describe("valid action files", () => {
|
||||
it("validates a minimal composite action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Does something
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "Hello"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates a node20 action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: A JavaScript action
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates a docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: A Docker action
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates an action with inputs and outputs", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Action with I/O
|
||||
inputs:
|
||||
name:
|
||||
description: The name to greet
|
||||
required: true
|
||||
greeting:
|
||||
description: The greeting
|
||||
default: Hello
|
||||
outputs:
|
||||
result:
|
||||
description: The greeting result
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "$\{{ inputs.greeting }} $\{{ inputs.name }}"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates an action with branding", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Branded action
|
||||
branding:
|
||||
icon: activity
|
||||
color: blue
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid action files", () => {
|
||||
it("reports error for missing required name", async () => {
|
||||
const doc = createActionDocument(`
|
||||
description: An action without a name
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "Hi"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("name");
|
||||
});
|
||||
|
||||
it("reports error for missing required description", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "Hi"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("description");
|
||||
});
|
||||
|
||||
it("reports error for missing runs", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: An action without runs
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("runs");
|
||||
});
|
||||
|
||||
it("reports error for missing using in runs", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Missing using
|
||||
runs:
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("using");
|
||||
});
|
||||
|
||||
it("reports error for invalid branding icon", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Bad icon
|
||||
branding:
|
||||
icon: not-a-real-icon
|
||||
color: blue
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("not-a-real-icon");
|
||||
});
|
||||
|
||||
it("reports error for invalid branding color", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Bad color
|
||||
branding:
|
||||
icon: activity
|
||||
color: pink
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("pink");
|
||||
});
|
||||
|
||||
it("reports error for composite step missing shell", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Missing shell
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "Hi"
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("shell");
|
||||
});
|
||||
|
||||
it("reports error for invalid YAML syntax", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Bad YAML
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: |
|
||||
echo "Bad indentation"
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("document type routing", () => {
|
||||
it("routes action.yml to action validation", async () => {
|
||||
const doc = createActionDocument(
|
||||
`
|
||||
name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`,
|
||||
"file:///my-repo/action.yml"
|
||||
);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes action.yaml to action validation", async () => {
|
||||
const doc = createActionDocument(
|
||||
`
|
||||
name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
`,
|
||||
"file:///my-repo/action.yaml"
|
||||
);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes nested action.yml to action validation", async () => {
|
||||
const doc = createActionDocument(
|
||||
`
|
||||
name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo test
|
||||
shell: bash
|
||||
`,
|
||||
"file:///my-repo/.github/actions/my-action/action.yml"
|
||||
);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composite action step validation", () => {
|
||||
it("validates action inputs in composite action uses steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Composite Action
|
||||
description: A composite action with uses steps
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
invalid-input: value
|
||||
`);
|
||||
const mockMetadataProvider = {
|
||||
fetchActionMetadata: () =>
|
||||
Promise.resolve({
|
||||
name: "Checkout",
|
||||
description: "Checkout a repo",
|
||||
inputs: {
|
||||
repository: {description: "Repository name", required: false},
|
||||
ref: {description: "Branch or tag", required: false}
|
||||
}
|
||||
})
|
||||
};
|
||||
const diagnostics = await validate(doc, {actionsMetadataProvider: mockMetadataProvider});
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("invalid-input");
|
||||
});
|
||||
|
||||
it("validates required inputs in composite action uses steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Composite Action
|
||||
description: A composite action with uses steps
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/some-action@v1
|
||||
`);
|
||||
const mockMetadataProvider = {
|
||||
fetchActionMetadata: () =>
|
||||
Promise.resolve({
|
||||
name: "Some Action",
|
||||
description: "An action with required inputs",
|
||||
inputs: {
|
||||
"required-input": {description: "A required input", required: true}
|
||||
}
|
||||
})
|
||||
};
|
||||
const diagnostics = await validate(doc, {actionsMetadataProvider: mockMetadataProvider});
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("required-input");
|
||||
});
|
||||
|
||||
it("reports unresolved action in composite action uses steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Composite Action
|
||||
description: A composite action with uses steps
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/nonexistent@v1
|
||||
`);
|
||||
const mockMetadataProvider = {
|
||||
fetchActionMetadata: () => Promise.resolve(undefined)
|
||||
};
|
||||
const diagnostics = await validate(doc, {actionsMetadataProvider: mockMetadataProvider});
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0].message).toContain("Unable to resolve action");
|
||||
});
|
||||
|
||||
it("passes validation for valid composite action uses steps", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Composite Action
|
||||
description: A composite action with uses steps
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: owner/repo
|
||||
`);
|
||||
const mockMetadataProvider = {
|
||||
fetchActionMetadata: () =>
|
||||
Promise.resolve({
|
||||
name: "Checkout",
|
||||
description: "Checkout a repo",
|
||||
inputs: {
|
||||
repository: {description: "Repository name", required: false},
|
||||
ref: {description: "Branch or tag", required: false}
|
||||
}
|
||||
})
|
||||
};
|
||||
const diagnostics = await validate(doc, {actionsMetadataProvider: mockMetadataProvider});
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +1,104 @@
|
||||
/**
|
||||
* Validation for action.yml / action.yaml manifest files
|
||||
*/
|
||||
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {ScalarToken} from "@actions/workflow-parser/templates/tokens/scalar-token";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {parseActionReference} from "./action.js";
|
||||
import {error} from "./log.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
|
||||
import {validateActionReference} from "./validate-action-reference.js";
|
||||
import {ValidationConfig} from "./validate.js";
|
||||
|
||||
export async function validateAction(
|
||||
diagnostics: Diagnostic[],
|
||||
stepToken: TemplateToken,
|
||||
step: Step | undefined,
|
||||
config: ValidationConfig | undefined
|
||||
): Promise<void> {
|
||||
if (!isMapping(stepToken) || !step || !isActionStep(step) || !config?.actionsMetadataProvider) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Validates an action.yml file
|
||||
*
|
||||
* @param textDocument Document to validate
|
||||
* @param config Optional validation configuration for action metadata provider
|
||||
* @returns Array of diagnostics
|
||||
*/
|
||||
export async function validateAction(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
|
||||
const file: File = {
|
||||
name: textDocument.uri,
|
||||
content: textDocument.getText()
|
||||
};
|
||||
|
||||
const action = parseActionReference(step.uses.value);
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
const actionMetadata = await config.actionsMetadataProvider.fetchActionMetadata(action);
|
||||
if (actionMetadata === undefined) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(step.uses.range),
|
||||
message: `Unable to resolve action \`${step.uses.value}\`, repository or version not found`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let withKey: ScalarToken | undefined;
|
||||
let withToken: TemplateToken | undefined;
|
||||
for (const {key, value} of stepToken) {
|
||||
if (key.toString() === "with") {
|
||||
withKey = key;
|
||||
withToken = value;
|
||||
break;
|
||||
try {
|
||||
// Parse and validate the action.yml against the schema
|
||||
const result = getOrParseAction(file, textDocument.uri);
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const stepInputs = new Map<string, ScalarToken>();
|
||||
if (withToken && isMapping(withToken)) {
|
||||
for (const {key} of withToken) {
|
||||
stepInputs.set(key.toString(), key);
|
||||
}
|
||||
}
|
||||
// Map parser errors to diagnostics
|
||||
for (const err of result.context.errors.getErrors()) {
|
||||
const range = mapRange(err.range);
|
||||
|
||||
const actionInputs = actionMetadata.inputs;
|
||||
if (actionInputs === undefined) {
|
||||
return;
|
||||
}
|
||||
// Determine severity based on error type
|
||||
let severity: DiagnosticSeverity = DiagnosticSeverity.Error;
|
||||
|
||||
// Treat deprecation warnings as warnings
|
||||
if (err.rawMessage.includes("deprecated")) {
|
||||
severity = DiagnosticSeverity.Warning;
|
||||
}
|
||||
|
||||
for (const [input, inputToken] of stepInputs) {
|
||||
if (!actionInputs[input]) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange(inputToken.range),
|
||||
message: `Invalid action input '${input}'`
|
||||
message: err.rawMessage,
|
||||
range,
|
||||
severity
|
||||
});
|
||||
}
|
||||
|
||||
const deprecationMessage = actionInputs[input]?.deprecationMessage;
|
||||
if (deprecationMessage) {
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Warning,
|
||||
range: mapRange(inputToken.range),
|
||||
message: deprecationMessage
|
||||
// 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
|
||||
});
|
||||
|
||||
// Only composite actions have steps to validate
|
||||
if (template?.runs?.using === "composite") {
|
||||
const steps = template.runs.steps ?? [];
|
||||
|
||||
// Find the steps sequence token from the raw parsed result
|
||||
const stepsSequence = findStepsSequence(result.value);
|
||||
if (stepsSequence) {
|
||||
// Validate each action step
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const stepToken = stepsSequence.get(i);
|
||||
|
||||
// Validate action references (inputs, required fields) for uses steps
|
||||
if (isActionStep(step) && isMapping(stepToken)) {
|
||||
await validateActionReference(diagnostics, stepToken, step, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error(`Unhandled error while validating action file: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
const missingRequiredInputs = Object.entries(actionInputs).filter(
|
||||
([inputName, input]) => input.required && !stepInputs.has(inputName) && input.default === undefined
|
||||
);
|
||||
|
||||
if (missingRequiredInputs.length > 0) {
|
||||
const message =
|
||||
missingRequiredInputs.length === 1
|
||||
? `Missing required input \`${missingRequiredInputs[0][0]}\``
|
||||
: `Missing required inputs: ${missingRequiredInputs.map(input => `\`${input[0]}\``).join(", ")}`;
|
||||
diagnostics.push({
|
||||
severity: DiagnosticSeverity.Error,
|
||||
range: mapRange((withKey || stepToken).range), // Highlight the whole step if we don't have a with key
|
||||
message: message
|
||||
});
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the steps sequence token from the raw action template.
|
||||
* Traverses the token tree looking for the "composite-steps" definition.
|
||||
*/
|
||||
function findStepsSequence(root: TemplateToken): SequenceToken | undefined {
|
||||
for (const [, token] of TemplateToken.traverse(root)) {
|
||||
if (token.definition?.key === "composite-steps" && token instanceof SequenceToken) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -211,4 +211,104 @@ jobs:
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// https://github.com/github/vscode-github-actions/issues/542
|
||||
describe("YAML-quoted expressions", () => {
|
||||
it("allows double-quoted expression in job-if", async () => {
|
||||
// Quotes are needed when the expression contains a colon
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
publish:
|
||||
if: "\${{ startsWith(github.event.head_commit.message, 'chore: release') }}"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "expression-literal-text-in-condition"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("allows single-quoted expression in job-if", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
publish:
|
||||
if: '\${{ startsWith(github.event.head_commit.message, "chore: release") }}'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "expression-literal-text-in-condition"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("allows double-quoted expression in step-if", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: "\${{ contains(github.event.head_commit.message, 'skip: ci') }}"
|
||||
run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "expression-literal-text-in-condition"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("still errors when there is actual literal text outside expression", async () => {
|
||||
// Even with quotes, if there's literal text outside ${{ }}, it should error
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
if: "push == \${{ github.event_name }}"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "expression-literal-text-in-condition"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("errors on multiple expressions with literal text between them", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
if: "\${{ true }} and \${{ false }}"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "expression-literal-text-in-condition"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Lexer, Parser, data} from "@actions/expressions";
|
||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||
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";
|
||||
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||
@@ -15,15 +15,17 @@ import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types";
|
||||
import {ActionMetadata, ActionReference} from "./action.js";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
import {Mode, getContext} from "./context-providers/default.js";
|
||||
import {Mode, getWorkflowExpressionContext} from "./context-providers/default.js";
|
||||
import {WorkflowContext, getWorkflowContext} from "./context/workflow-context.js";
|
||||
import {wrapDictionary} from "./expression-validation/error-dictionary.js";
|
||||
import {ValidationEvaluator} from "./expression-validation/evaluator.js";
|
||||
import {validatorFunctions} from "./expression-validation/functions.js";
|
||||
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 {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||
import {validateActionReference} from "./validate-action-reference.js";
|
||||
import {validateAction} from "./validate-action.js";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||
import {defaultValueProviders} from "./value-providers/default.js";
|
||||
@@ -43,12 +45,24 @@ export type ActionsMetadataProvider = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a workflow file
|
||||
* Validates a workflow or action file
|
||||
*
|
||||
* @param textDocument Document to validate
|
||||
* @returns Array of diagnostics
|
||||
*/
|
||||
export async function validate(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
|
||||
return isActionDocument(textDocument.uri)
|
||||
? validateAction(textDocument, config)
|
||||
: validateWorkflow(textDocument, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a workflow file
|
||||
*
|
||||
* @param textDocument Document to validate
|
||||
* @returns Array of diagnostics
|
||||
*/
|
||||
async function validateWorkflow(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
|
||||
const file: File = {
|
||||
name: textDocument.uri,
|
||||
content: textDocument.getText()
|
||||
@@ -57,14 +71,14 @@ export async function validate(textDocument: TextDocument, config?: ValidationCo
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
try {
|
||||
const result: ParseWorkflowResult | undefined = fetchOrParseWorkflow(file, textDocument.uri);
|
||||
const result: TemplateParseResult | undefined = getOrParseWorkflow(file, textDocument.uri);
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (result.value) {
|
||||
// Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information
|
||||
const template = await fetchOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
|
||||
const template = await getOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
@@ -155,7 +169,7 @@ async function additionalValidations(
|
||||
// Validate action metadata (inputs, required fields) for regular steps
|
||||
if (token.definition?.key === "regular-step" && token.range) {
|
||||
const context = getProviderContext(documentUri, template, root, token.range);
|
||||
await validateAction(diagnostics, token, context.step, config);
|
||||
await validateActionReference(diagnostics, token, context.step, config);
|
||||
}
|
||||
|
||||
// Validate job-level reusable workflow uses field format
|
||||
@@ -180,7 +194,7 @@ async function additionalValidations(
|
||||
if (token.range && validationDefinition) {
|
||||
const defKey = validationDefinition.key;
|
||||
if (defKey === "step-with") {
|
||||
// Action inputs should be validated already in validateAction
|
||||
// Action inputs should be validated already in validateActionReference
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -721,7 +735,12 @@ async function validateExpression(
|
||||
continue;
|
||||
}
|
||||
|
||||
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
|
||||
const context = await getWorkflowExpressionContext(
|
||||
namedContexts,
|
||||
contextProviderConfig,
|
||||
workflowContext,
|
||||
Mode.Validation
|
||||
);
|
||||
|
||||
const e = new ValidationEvaluator(expr, wrapDictionary(context), validatorFunctions);
|
||||
e.validate();
|
||||
|
||||
@@ -7,8 +7,8 @@ export interface Value {
|
||||
/** Optional description to show when auto-completing */
|
||||
description?: string;
|
||||
|
||||
/** Optional detail shown after the label, e.g. type or kind information */
|
||||
detail?: string;
|
||||
/** Optional qualifier shown inline after the label, e.g. "full syntax" or "list" */
|
||||
labelDetail?: string;
|
||||
|
||||
/** Whether this value is deprecated */
|
||||
deprecated?: boolean;
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {MappingDefinition} from "@actions/workflow-parser/templates/schema/mappi
|
||||
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
||||
import {SequenceDefinition} from "@actions/workflow-parser/templates/schema/sequence-definition";
|
||||
import {StringDefinition} from "@actions/workflow-parser/templates/schema/string-definition";
|
||||
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
|
||||
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
|
||||
import {Value} from "./config.js";
|
||||
import {stringsToValues} from "./strings-to-values.js";
|
||||
|
||||
@@ -47,21 +47,21 @@ export type TokenStructure = "scalar" | "sequence" | "mapping" | undefined;
|
||||
* @param tokenStructure - If provided, filters completions to only those matching
|
||||
* the YAML structure the user has already started (e.g., only mapping keys if
|
||||
* they've started a mapping)
|
||||
* @param schema - The schema to use for definition lookups
|
||||
*/
|
||||
export function definitionValues(
|
||||
def: Definition,
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode,
|
||||
tokenStructure?: TokenStructure
|
||||
tokenStructure: TokenStructure | undefined,
|
||||
schema: TemplateSchema
|
||||
): Value[] {
|
||||
const schema = getWorkflowSchema();
|
||||
|
||||
if (def instanceof MappingDefinition) {
|
||||
return mappingValues(def, schema.definitions, indentation, mode);
|
||||
}
|
||||
|
||||
if (def instanceof OneOfDefinition) {
|
||||
return oneOfValues(def, schema.definitions, indentation, mode, tokenStructure);
|
||||
return oneOfValues(def, schema.definitions, indentation, mode, tokenStructure, schema);
|
||||
}
|
||||
|
||||
if (def instanceof BooleanDefinition) {
|
||||
@@ -80,7 +80,7 @@ export function definitionValues(
|
||||
if (def instanceof SequenceDefinition) {
|
||||
const itemDef = schema.getDefinition(def.itemType);
|
||||
if (itemDef) {
|
||||
return definitionValues(itemDef, indentation, mode);
|
||||
return definitionValues(itemDef, indentation, mode, undefined, schema);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -177,7 +181,8 @@ function oneOfValues(
|
||||
definitions: {[key: string]: Definition},
|
||||
indentation: string,
|
||||
mode: DefinitionValueMode,
|
||||
tokenStructure?: TokenStructure
|
||||
tokenStructure: TokenStructure | undefined,
|
||||
schema: TemplateSchema
|
||||
): Value[] {
|
||||
const values: Value[] = [];
|
||||
for (const key of oneOfDefinition.oneOf) {
|
||||
@@ -209,20 +214,20 @@ function oneOfValues(
|
||||
}
|
||||
}
|
||||
|
||||
values.push(...definitionValues(variantDef, indentation, mode, tokenStructure));
|
||||
values.push(...definitionValues(variantDef, indentation, mode, tokenStructure, schema));
|
||||
}
|
||||
return distinctValues(values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates values by label and detail.
|
||||
* Values with the same label but different details are preserved as distinct items.
|
||||
* Deduplicates values by label and labelDetail.
|
||||
* Values with the same label but different labelDetails are preserved as distinct items.
|
||||
*/
|
||||
function distinctValues(values: Value[]): Value[] {
|
||||
const map = new Map<string, Value>();
|
||||
for (const value of values) {
|
||||
// Include detail in the key to preserve variants with different details
|
||||
const key = value.detail ? `${value.label}\0${value.detail}` : value.label;
|
||||
// Include labelDetail in the key to preserve variants with different details
|
||||
const key = value.labelDetail ? `${value.label}\0${value.labelDetail}` : value.label;
|
||||
map.set(key, value);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
@@ -325,7 +330,7 @@ function expandOneOfToCompletions(
|
||||
results.push({
|
||||
label: key,
|
||||
description,
|
||||
detail: needsQualifier ? "list" : undefined,
|
||||
labelDetail: needsQualifier ? "list" : undefined,
|
||||
insertText,
|
||||
sortText: needsQualifier ? `${key} 1` : undefined
|
||||
});
|
||||
@@ -339,7 +344,7 @@ function expandOneOfToCompletions(
|
||||
results.push({
|
||||
label: key,
|
||||
description,
|
||||
detail: needsQualifier ? "full syntax" : undefined,
|
||||
labelDetail: needsQualifier ? "full syntax" : undefined,
|
||||
insertText,
|
||||
sortText: needsQualifier ? `${key} 2` : undefined
|
||||
});
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.30"
|
||||
"version": "0.3.34"
|
||||
}
|
||||
Generated
+9
-9
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"expressions": {
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.30",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -396,11 +396,11 @@
|
||||
},
|
||||
"languageserver": {
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.30",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.30",
|
||||
"@actions/workflow-parser": "^0.3.30",
|
||||
"@actions/languageservice": "^0.3.34",
|
||||
"@actions/workflow-parser": "^0.3.34",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -940,11 +940,11 @@
|
||||
},
|
||||
"languageservice": {
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.30",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.30",
|
||||
"@actions/workflow-parser": "^0.3.30",
|
||||
"@actions/expressions": "^0.3.34",
|
||||
"@actions/workflow-parser": "^0.3.34",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
@@ -13345,10 +13345,10 @@
|
||||
},
|
||||
"workflow-parser": {
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.30",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.30",
|
||||
"@actions/expressions": "^0.3.34",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.30",
|
||||
"version": "0.3.34",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -36,9 +36,9 @@
|
||||
"clean": "rimraf dist",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"minify-json": "node ../script/minify-json.js src/workflow-v1.0.json",
|
||||
"minify-json": "node ../script/minify-json.js src/workflow-v1.0.json && node ../script/minify-json.js src/action-v1.0.json",
|
||||
"prebuild": "npm run minify-json",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"pretest": "npm run minify-json",
|
||||
@@ -48,7 +48,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.30",
|
||||
"@actions/expressions": "^0.3.34",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
{
|
||||
"definitions": {
|
||||
"action-root": {
|
||||
"description": "Action file",
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"inputs": "inputs",
|
||||
"outputs": "outputs",
|
||||
"runs": "runs"
|
||||
},
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "any"
|
||||
}
|
||||
},
|
||||
"action-root-strict": {
|
||||
"description": "GitHub Action manifest file (action.yml/action.yaml) that defines an action's metadata, inputs, outputs, and execution configuration.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions)",
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "non-empty-string",
|
||||
"required": true,
|
||||
"description": "The name of your action. GitHub displays the name in the Actions tab to help visually identify actions in each job.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#name)"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"description": "A short description of the action. GitHub displays this description in the Actions Marketplace.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#description)"
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"description": "The name of the action's author.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#author)"
|
||||
},
|
||||
"inputs": "inputs-strict",
|
||||
"outputs": "outputs",
|
||||
"runs": {
|
||||
"type": "runs-strict",
|
||||
"required": true
|
||||
},
|
||||
"branding": "branding"
|
||||
}
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"mapping": {
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "input"
|
||||
}
|
||||
},
|
||||
"inputs-strict": {
|
||||
"description": "Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Inputs ids with uppercase letters are converted to lowercase during runtime.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputs)",
|
||||
"mapping": {
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "input-strict"
|
||||
}
|
||||
},
|
||||
"input": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"default": "input-default-context"
|
||||
},
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "any"
|
||||
}
|
||||
},
|
||||
"input-strict": {
|
||||
"description": "An input parameter for this action.",
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A string description of the input parameter.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_iddescription)"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"description": "A boolean to indicate whether the action requires the input parameter. Set to true when the parameter is required.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_idrequired)"
|
||||
},
|
||||
"default": {
|
||||
"type": "input-default-context",
|
||||
"description": "A string representing the default value. The default value is used when an input parameter isn't specified in a workflow file.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_iddefault)"
|
||||
},
|
||||
"deprecationMessage": {
|
||||
"type": "string",
|
||||
"description": "A string shown to users using the deprecated input, warning them that the input is deprecated and mentioning any alternatives.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_iddeprecationmessage)"
|
||||
}
|
||||
},
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "any"
|
||||
}
|
||||
},
|
||||
"input-default-context": {
|
||||
"description": "A string representing the default value. The default value is used when an input parameter isn't specified in a workflow file.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_iddefault)",
|
||||
"context": [
|
||||
"github",
|
||||
"strategy",
|
||||
"matrix",
|
||||
"job",
|
||||
"runner",
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"string": {}
|
||||
},
|
||||
"outputs": {
|
||||
"description": "Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-composite-actions)",
|
||||
"mapping": {
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "output-definition"
|
||||
}
|
||||
},
|
||||
"output-definition": {
|
||||
"description": "An output parameter for this action.",
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A string description of the output parameter.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#outputsoutput_iddescription)"
|
||||
},
|
||||
"value": {
|
||||
"type": "output-value",
|
||||
"description": "The value that the output parameter will be mapped to. You can set this to a string or an expression with context.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#outputsoutput_idvalue)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"output-value": {
|
||||
"description": "The value that the output parameter will be mapped to. You can set this to a string or an expression with context.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#outputsoutput_idvalue)",
|
||||
"context": [
|
||||
"github",
|
||||
"strategy",
|
||||
"matrix",
|
||||
"steps",
|
||||
"inputs",
|
||||
"job",
|
||||
"runner",
|
||||
"env"
|
||||
],
|
||||
"string": {}
|
||||
},
|
||||
"runs": {
|
||||
"one-of": [
|
||||
"container-runs",
|
||||
"node-runs",
|
||||
"composite-runs",
|
||||
"plugin-runs"
|
||||
]
|
||||
},
|
||||
"runs-strict": {
|
||||
"description": "Specifies whether this is a JavaScript action, a composite action, or a Docker container action and how the action is executed.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runs)",
|
||||
"one-of": [
|
||||
"container-runs-strict",
|
||||
"node-runs-strict",
|
||||
"composite-runs-strict"
|
||||
]
|
||||
},
|
||||
"plugin-runs": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"plugin": "non-empty-string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"container-runs": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"using": "non-empty-string",
|
||||
"image": "non-empty-string",
|
||||
"entrypoint": "non-empty-string",
|
||||
"args": "container-runs-args",
|
||||
"env": "container-runs-env",
|
||||
"pre-entrypoint": "non-empty-string",
|
||||
"pre-if": "non-empty-string",
|
||||
"post-entrypoint": "non-empty-string",
|
||||
"post-if": "non-empty-string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"container-runs-args": {
|
||||
"description": "An array of strings that define the inputs for a Docker container. Inputs can include hardcoded strings. GitHub passes the args to the container's ENTRYPOINT when the container starts up.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsargs)",
|
||||
"sequence": {
|
||||
"item-type": "container-runs-context"
|
||||
}
|
||||
},
|
||||
"container-runs-env": {
|
||||
"description": "Specifies a key/value map of environment variables to set in the container environment.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsenv)",
|
||||
"context": [
|
||||
"inputs"
|
||||
],
|
||||
"mapping": {
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "string"
|
||||
}
|
||||
},
|
||||
"container-runs-context": {
|
||||
"context": [
|
||||
"inputs"
|
||||
],
|
||||
"string": {}
|
||||
},
|
||||
"node-runs": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"using": "non-empty-string",
|
||||
"main": "non-empty-string",
|
||||
"pre": "non-empty-string",
|
||||
"pre-if": "non-empty-string",
|
||||
"post": "non-empty-string",
|
||||
"post-if": "non-empty-string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"composite-runs": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"using": "non-empty-string",
|
||||
"steps": "composite-steps"
|
||||
}
|
||||
}
|
||||
},
|
||||
"container-runs-strict": {
|
||||
"description": "Configuration for Docker container actions.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runs)",
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"using": {
|
||||
"type": "using",
|
||||
"required": true,
|
||||
"description": "The runtime used to execute the action. Must be docker for Docker container actions.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsusing)"
|
||||
},
|
||||
"image": {
|
||||
"type": "non-empty-string",
|
||||
"required": true,
|
||||
"description": "The Docker image to use as the container to run the action. The value can be the Docker base image name, a local Dockerfile in your repository, or a public image in Docker Hub or another registry.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsimage)"
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "non-empty-string",
|
||||
"description": "Overrides the Docker ENTRYPOINT in the Dockerfile, or sets it if one wasn't already specified.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsentrypoint)"
|
||||
},
|
||||
"args": "container-runs-args",
|
||||
"env": "container-runs-env",
|
||||
"pre-entrypoint": {
|
||||
"type": "non-empty-string",
|
||||
"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",
|
||||
"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": {
|
||||
"type": "non-empty-string",
|
||||
"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",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"node-runs-strict": {
|
||||
"description": "Configuration for JavaScript actions executed with Node.js.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runs)",
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"using": {
|
||||
"type": "using",
|
||||
"required": true,
|
||||
"description": "The runtime used to execute the action. Use node20 or node24 for JavaScript actions.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsusing)"
|
||||
},
|
||||
"main": {
|
||||
"type": "non-empty-string",
|
||||
"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": {
|
||||
"type": "non-empty-string",
|
||||
"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",
|
||||
"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": {
|
||||
"type": "non-empty-string",
|
||||
"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",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"composite-runs-strict": {
|
||||
"description": "Configuration for composite actions that run multiple steps.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runs)",
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"using": {
|
||||
"type": "using",
|
||||
"required": true,
|
||||
"description": "The runtime used to execute the action. Must be composite for composite actions.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsusing)"
|
||||
},
|
||||
"steps": {
|
||||
"type": "composite-steps",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"composite-steps": {
|
||||
"description": "The steps that you plan to run in this action. These can be either run steps or uses steps.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runssteps)",
|
||||
"sequence": {
|
||||
"item-type": "composite-step"
|
||||
}
|
||||
},
|
||||
"composite-step": {
|
||||
"description": "A step within a composite action.",
|
||||
"one-of": [
|
||||
"run-step",
|
||||
"uses-step"
|
||||
]
|
||||
},
|
||||
"run-step": {
|
||||
"description": "Runs a command-line program using the operating system's shell.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsrun)",
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string-steps-context",
|
||||
"description": "A name for your step to display on GitHub.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsname)"
|
||||
},
|
||||
"id": {
|
||||
"type": "non-empty-string",
|
||||
"description": "A unique identifier for the step. You can use the id to reference the step in contexts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsid)"
|
||||
},
|
||||
"if": {
|
||||
"type": "step-if",
|
||||
"description": "You can use the if conditional to prevent a step from running unless a condition is met. You can use any supported context and expression to create a conditional.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsif)"
|
||||
},
|
||||
"run": {
|
||||
"type": "string-steps-context",
|
||||
"required": true,
|
||||
"description": "The command you want to run. This can be inline or a script in your action repository.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsrun)"
|
||||
},
|
||||
"shell": {
|
||||
"type": "string-steps-context",
|
||||
"required": true,
|
||||
"description": "The shell where you want to run the command. Any shell supported by the runner can be used. Required if run is set.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsshell)"
|
||||
},
|
||||
"env": "step-env",
|
||||
"continue-on-error": {
|
||||
"type": "boolean-steps-context",
|
||||
"description": "Prevents the action from failing when a step fails. Set to true to allow the action to pass when this step fails.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepscontinue-on-error)"
|
||||
},
|
||||
"working-directory": {
|
||||
"type": "string-steps-context",
|
||||
"description": "Specifies the working directory where the command is run.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsworking-directory)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uses-step": {
|
||||
"description": "Runs another action as part of a step in your action.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsuses)",
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string-steps-context",
|
||||
"description": "A name for your step to display on GitHub.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsname)"
|
||||
},
|
||||
"id": {
|
||||
"type": "non-empty-string",
|
||||
"description": "A unique identifier for the step. You can use the id to reference the step in contexts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsid)"
|
||||
},
|
||||
"if": {
|
||||
"type": "step-if",
|
||||
"description": "You can use the if conditional to prevent a step from running unless a condition is met. You can use any supported context and expression to create a conditional.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsif)"
|
||||
},
|
||||
"uses": {
|
||||
"type": "non-empty-string",
|
||||
"required": true,
|
||||
"description": "Selects an action to run as part of a step in your action. An action is a reusable unit of code.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsuses)"
|
||||
},
|
||||
"with": "step-with",
|
||||
"env": "step-env",
|
||||
"continue-on-error": {
|
||||
"type": "boolean-steps-context",
|
||||
"description": "Prevents the action from failing when a step fails. Set to true to allow the action to pass when this step fails.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepscontinue-on-error)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"string-steps-context": {
|
||||
"context": [
|
||||
"github",
|
||||
"inputs",
|
||||
"strategy",
|
||||
"matrix",
|
||||
"steps",
|
||||
"job",
|
||||
"runner",
|
||||
"env",
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"string": {}
|
||||
},
|
||||
"boolean-steps-context": {
|
||||
"context": [
|
||||
"github",
|
||||
"inputs",
|
||||
"strategy",
|
||||
"matrix",
|
||||
"steps",
|
||||
"job",
|
||||
"runner",
|
||||
"env",
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"boolean": {}
|
||||
},
|
||||
"step-env": {
|
||||
"description": "Sets variables for steps to use in the runner environment. You can also set variables for the entire action.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsenv)",
|
||||
"context": [
|
||||
"github",
|
||||
"inputs",
|
||||
"strategy",
|
||||
"matrix",
|
||||
"steps",
|
||||
"job",
|
||||
"runner",
|
||||
"env",
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"mapping": {
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "string"
|
||||
}
|
||||
},
|
||||
"step-if": {
|
||||
"description": "You can use the if conditional to prevent a step from running unless a condition is met. You can use any supported context and expression to create a conditional.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsif)",
|
||||
"context": [
|
||||
"github",
|
||||
"inputs",
|
||||
"strategy",
|
||||
"matrix",
|
||||
"steps",
|
||||
"job",
|
||||
"runner",
|
||||
"env",
|
||||
"always(0,0)",
|
||||
"failure(0,0)",
|
||||
"cancelled(0,0)",
|
||||
"success(0,0)",
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"string": {}
|
||||
},
|
||||
"step-with": {
|
||||
"description": "A map of the input parameters defined by the action. Each input parameter is a key/value pair.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runsstepswith)",
|
||||
"context": [
|
||||
"github",
|
||||
"inputs",
|
||||
"strategy",
|
||||
"matrix",
|
||||
"steps",
|
||||
"job",
|
||||
"runner",
|
||||
"env",
|
||||
"hashFiles(1,255)"
|
||||
],
|
||||
"mapping": {
|
||||
"loose-key-type": "non-empty-string",
|
||||
"loose-value-type": "string"
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"description": "You can use a color and Feather icon to create a badge to personalize and distinguish your action in GitHub Marketplace.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#branding)",
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"icon": {
|
||||
"type": "branding-icon",
|
||||
"description": "The name of the v4.28.0 Feather icon to use.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#brandingicon)"
|
||||
},
|
||||
"color": {
|
||||
"type": "branding-color",
|
||||
"description": "The background color of the badge.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#brandingcolor)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"branding-icon": {
|
||||
"description": "The name of the v4.28.0 Feather icon to use. Brand icons are omitted as well as: coffee, columns, divide-circle, divide-square, divide, frown, hexagon, key, meh, mouse-pointer, smile, tool, x-octagon.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#brandingicon)",
|
||||
"allowed-values": [
|
||||
"activity", "airplay", "alert-circle", "alert-octagon", "alert-triangle",
|
||||
"align-center", "align-justify", "align-left", "align-right", "anchor",
|
||||
"aperture", "archive", "arrow-down-circle", "arrow-down-left", "arrow-down-right",
|
||||
"arrow-down", "arrow-left-circle", "arrow-left", "arrow-right-circle", "arrow-right",
|
||||
"arrow-up-circle", "arrow-up-left", "arrow-up-right", "arrow-up", "at-sign",
|
||||
"award", "bar-chart-2", "bar-chart", "battery-charging", "battery",
|
||||
"bell-off", "bell", "bluetooth", "bold", "book-open",
|
||||
"book", "bookmark", "box", "briefcase", "calendar",
|
||||
"camera-off", "camera", "cast", "check-circle", "check-square",
|
||||
"check", "chevron-down", "chevron-left", "chevron-right", "chevron-up",
|
||||
"chevrons-down", "chevrons-left", "chevrons-right", "chevrons-up", "circle",
|
||||
"clipboard", "clock", "cloud-drizzle", "cloud-lightning", "cloud-off",
|
||||
"cloud-rain", "cloud-snow", "cloud", "code", "command",
|
||||
"compass", "copy", "corner-down-left", "corner-down-right", "corner-left-down",
|
||||
"corner-left-up", "corner-right-down", "corner-right-up", "corner-up-left", "corner-up-right",
|
||||
"cpu", "credit-card", "crop", "crosshair", "database",
|
||||
"delete", "disc", "dollar-sign", "download-cloud", "download",
|
||||
"droplet", "edit-2", "edit-3", "edit", "external-link",
|
||||
"eye-off", "eye", "fast-forward", "feather", "file-minus",
|
||||
"file-plus", "file-text", "file", "film", "filter",
|
||||
"flag", "folder-minus", "folder-plus", "folder", "gift",
|
||||
"git-branch", "git-commit", "git-merge", "git-pull-request", "globe",
|
||||
"grid", "hard-drive", "hash", "headphones", "heart",
|
||||
"help-circle", "home", "image", "inbox", "info",
|
||||
"italic", "layers", "layout", "life-buoy", "link-2",
|
||||
"link", "list", "loader", "lock", "log-in",
|
||||
"log-out", "mail", "map-pin", "map", "maximize-2",
|
||||
"maximize", "menu", "message-circle", "message-square", "mic-off",
|
||||
"mic", "minimize-2", "minimize", "minus-circle", "minus-square",
|
||||
"minus", "monitor", "moon", "more-horizontal", "more-vertical",
|
||||
"move", "music", "navigation-2", "navigation", "octagon",
|
||||
"package", "paperclip", "pause-circle", "pause", "percent",
|
||||
"phone-call", "phone-forwarded", "phone-incoming", "phone-missed", "phone-off",
|
||||
"phone-outgoing", "phone", "pie-chart", "play-circle", "play",
|
||||
"plus-circle", "plus-square", "plus", "pocket", "power",
|
||||
"printer", "radio", "refresh-ccw", "refresh-cw", "repeat",
|
||||
"rewind", "rotate-ccw", "rotate-cw", "rss", "save",
|
||||
"scissors", "search", "send", "server", "settings",
|
||||
"share-2", "share", "shield-off", "shield", "shopping-bag",
|
||||
"shopping-cart", "shuffle", "sidebar", "skip-back", "skip-forward",
|
||||
"slash", "sliders", "smartphone", "speaker", "square",
|
||||
"star", "stop-circle", "sun", "sunrise", "sunset",
|
||||
"tablet", "tag", "target", "terminal", "thermometer",
|
||||
"thumbs-down", "thumbs-up", "toggle-left", "toggle-right", "trash-2",
|
||||
"trash", "trending-down", "trending-up", "triangle", "truck",
|
||||
"tv", "type", "umbrella", "underline", "unlock",
|
||||
"upload-cloud", "upload", "user-check", "user-minus", "user-plus",
|
||||
"user-x", "user", "users", "video-off", "video",
|
||||
"voicemail", "volume-1", "volume-2", "volume-x", "volume",
|
||||
"watch", "wifi-off", "wifi", "wind", "x-circle",
|
||||
"x-square", "x", "zap-off", "zap", "zoom-in", "zoom-out"
|
||||
]
|
||||
},
|
||||
"branding-color": {
|
||||
"description": "The background color of the badge.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#brandingcolor)",
|
||||
"allowed-values": ["white", "yellow", "blue", "green", "orange", "red", "purple", "gray-dark"]
|
||||
},
|
||||
"using": {
|
||||
"description": "The runtime used to execute the action.",
|
||||
"allowed-values": ["docker", "node12", "node16", "node20", "node24", "composite"]
|
||||
},
|
||||
"non-empty-string": {
|
||||
"string": {
|
||||
"require-non-empty": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const ACTION_ROOT = "action-root-strict";
|
||||
@@ -0,0 +1,320 @@
|
||||
import {parseAction} from "./action-parser.js";
|
||||
import {convertActionTemplate} from "./action-template.js";
|
||||
import {nullTrace} from "../test-utils/null-trace.js";
|
||||
|
||||
describe("parseAction", () => {
|
||||
it("parses a minimal action.yml", () => {
|
||||
const content = `
|
||||
name: My Action
|
||||
description: A simple action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo Hello
|
||||
shell: bash`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
|
||||
expect(result.context.errors.count).toBe(0);
|
||||
expect(result.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses a JavaScript action", () => {
|
||||
const content = `
|
||||
name: JS Action
|
||||
description: A JavaScript action
|
||||
runs:
|
||||
using: node20
|
||||
main: dist/index.js
|
||||
pre: dist/setup.js
|
||||
post: dist/cleanup.js`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
|
||||
expect(result.context.errors.count).toBe(0);
|
||||
expect(result.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses a Docker action", () => {
|
||||
const content = `
|
||||
name: Docker Action
|
||||
description: A Docker action
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
args:
|
||||
- \${{ inputs.name }}
|
||||
env:
|
||||
DEBUG: "true"`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
|
||||
expect(result.context.errors.count).toBe(0);
|
||||
expect(result.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses action with inputs and outputs", () => {
|
||||
const content = `
|
||||
name: Action with I/O
|
||||
description: Action with inputs and outputs
|
||||
inputs:
|
||||
name:
|
||||
description: The name to greet
|
||||
required: true
|
||||
default: World
|
||||
verbose:
|
||||
description: Enable verbose mode
|
||||
required: false
|
||||
outputs:
|
||||
greeting:
|
||||
description: The greeting message
|
||||
value: \${{ steps.greet.outputs.message }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: greet
|
||||
run: echo "::set-output name=message::Hello \${{ inputs.name }}"
|
||||
shell: bash`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
|
||||
expect(result.context.errors.count).toBe(0);
|
||||
expect(result.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses action with branding", () => {
|
||||
const content = `
|
||||
name: Branded Action
|
||||
description: Action with branding
|
||||
branding:
|
||||
icon: award
|
||||
color: blue
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo Hello
|
||||
shell: bash`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
|
||||
expect(result.context.errors.count).toBe(0);
|
||||
expect(result.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("reports error for invalid YAML", () => {
|
||||
const content = `
|
||||
name: Invalid Action
|
||||
description: Action with bad YAML
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: 'Hello \${{ fromJSON('test') }}'
|
||||
run: echo test
|
||||
shell: bash`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
|
||||
expect(result.context.errors.count).toBeGreaterThan(0);
|
||||
expect(result.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates required fields", () => {
|
||||
const content = `
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo Hello
|
||||
shell: bash`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
|
||||
expect(result.context.errors.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("validates shell is required for run steps", () => {
|
||||
const content = `
|
||||
name: Missing Shell
|
||||
description: Action without shell in run step
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo Hello`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
|
||||
expect(result.context.errors.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("validates branding icon values", () => {
|
||||
const content = `
|
||||
name: Bad Icon
|
||||
description: Action with invalid branding icon
|
||||
branding:
|
||||
icon: invalid-icon-name
|
||||
color: blue
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo Hello
|
||||
shell: bash`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
|
||||
// Should have error for invalid icon value
|
||||
expect(result.context.errors.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("validates branding color values", () => {
|
||||
const content = `
|
||||
name: Bad Color
|
||||
description: Action with invalid branding color
|
||||
branding:
|
||||
icon: award
|
||||
color: pink
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo Hello
|
||||
shell: bash`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
|
||||
// Should have error for invalid color value
|
||||
expect(result.context.errors.count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertActionTemplate", () => {
|
||||
it("converts a composite action", () => {
|
||||
const content = `
|
||||
name: Composite Action
|
||||
description: A composite action
|
||||
author: Test Author
|
||||
inputs:
|
||||
name:
|
||||
description: The name
|
||||
required: true
|
||||
default: World
|
||||
outputs:
|
||||
result:
|
||||
description: The result
|
||||
value: \${{ steps.main.outputs.result }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: main
|
||||
name: Main step
|
||||
run: echo Hello \${{ inputs.name }}
|
||||
shell: bash
|
||||
branding:
|
||||
icon: star
|
||||
color: green`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
expect(result.value).toBeDefined();
|
||||
if (!result.value) return;
|
||||
|
||||
const template = convertActionTemplate(result.context, result.value);
|
||||
|
||||
expect(template.name).toBe("Composite Action");
|
||||
expect(template.description).toBe("A composite action");
|
||||
expect(template.author).toBe("Test Author");
|
||||
expect(template.inputs).toHaveLength(1);
|
||||
expect(template.inputs?.[0].id).toBe("name");
|
||||
expect(template.inputs?.[0].required).toBe(true);
|
||||
expect(template.outputs).toHaveLength(1);
|
||||
expect(template.outputs?.[0].id).toBe("result");
|
||||
expect(template.runs.using).toBe("composite");
|
||||
expect(template.branding?.icon).toBe("star");
|
||||
expect(template.branding?.color).toBe("green");
|
||||
|
||||
if (template.runs.using === "composite") {
|
||||
expect(template.runs.steps).toHaveLength(1);
|
||||
expect("run" in template.runs.steps[0]).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("converts a node action", () => {
|
||||
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'
|
||||
post: dist/cleanup.js
|
||||
post-if: always()`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
expect(result.value).toBeDefined();
|
||||
if (!result.value) return;
|
||||
|
||||
const template = convertActionTemplate(result.context, result.value);
|
||||
|
||||
expect(template.runs.using).toBe("node20");
|
||||
if (template.runs.using === "node20") {
|
||||
expect(template.runs.main).toBe("dist/index.js");
|
||||
expect(template.runs.pre).toBe("dist/setup.js");
|
||||
expect(template.runs.preIf).toBe("runner.os == 'Linux'");
|
||||
expect(template.runs.post).toBe("dist/cleanup.js");
|
||||
expect(template.runs.postIf).toBe("always()");
|
||||
}
|
||||
});
|
||||
|
||||
it("converts a docker action", () => {
|
||||
const content = `
|
||||
name: Docker Action
|
||||
description: A docker action
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
entrypoint: /entrypoint.sh
|
||||
args:
|
||||
- --name
|
||||
- \${{ inputs.name }}
|
||||
env:
|
||||
DEBUG: "true"`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
expect(result.value).toBeDefined();
|
||||
if (!result.value) return;
|
||||
|
||||
const template = convertActionTemplate(result.context, result.value);
|
||||
|
||||
expect(template.runs.using).toBe("docker");
|
||||
if (template.runs.using === "docker") {
|
||||
expect(template.runs.image).toBe("Dockerfile");
|
||||
expect(template.runs.entrypoint).toBe("/entrypoint.sh");
|
||||
expect(template.runs.args).toEqual(["--name", "${{ inputs.name }}"]);
|
||||
expect(template.runs.env).toEqual({DEBUG: "true"});
|
||||
}
|
||||
});
|
||||
|
||||
it("converts uses steps in composite action", () => {
|
||||
const content = `
|
||||
name: Composite with Uses
|
||||
description: Composite action with uses steps
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
expect(result.value).toBeDefined();
|
||||
if (!result.value) return;
|
||||
|
||||
const template = convertActionTemplate(result.context, result.value);
|
||||
|
||||
if (template.runs.using === "composite") {
|
||||
expect(template.runs.steps).toHaveLength(1);
|
||||
const step = template.runs.steps[0];
|
||||
expect("uses" in step).toBe(true);
|
||||
if ("uses" in step) {
|
||||
expect(step.uses.value).toBe("actions/checkout@v4");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import {TemplateParseResult} from "../templates/template-parse-result.js";
|
||||
import {TemplateContext, TemplateValidationErrors} from "../templates/template-context.js";
|
||||
import * as templateReader from "../templates/template-reader.js";
|
||||
import {TraceWriter} from "../templates/trace-writer.js";
|
||||
import {File} from "../workflows/file.js";
|
||||
import {YamlObjectReader} from "../workflows/yaml-object-reader.js";
|
||||
import {ACTION_ROOT} from "./action-constants.js";
|
||||
import {getActionSchema} from "./action-schema.js";
|
||||
|
||||
/**
|
||||
* Parses an action.yml file and validates it against the action schema.
|
||||
* Returns a TemplateParseResult containing the parsed template token tree
|
||||
* and any validation errors found during parsing.
|
||||
*/
|
||||
export function parseAction(entryFile: File, trace: TraceWriter): TemplateParseResult;
|
||||
export function parseAction(entryFile: File, context: TemplateContext): TemplateParseResult;
|
||||
export function parseAction(entryFile: File, contextOrTrace: TraceWriter | TemplateContext): TemplateParseResult {
|
||||
const context =
|
||||
contextOrTrace instanceof TemplateContext
|
||||
? contextOrTrace
|
||||
: new TemplateContext(new TemplateValidationErrors(), getActionSchema(), contextOrTrace);
|
||||
|
||||
const fileId = context.getFileId(entryFile.name);
|
||||
const reader = new YamlObjectReader(fileId, entryFile.content);
|
||||
if (reader.errors.length > 0) {
|
||||
// The file is not valid YAML, template errors could be misleading
|
||||
for (const err of reader.errors) {
|
||||
context.error(fileId, err.message, err.range);
|
||||
}
|
||||
return {
|
||||
context,
|
||||
value: undefined
|
||||
};
|
||||
}
|
||||
const result = templateReader.readTemplate(context, ACTION_ROOT, reader, fileId);
|
||||
|
||||
return <TemplateParseResult>{
|
||||
context,
|
||||
value: result
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {JSONObjectReader} from "../templates/json-object-reader.js";
|
||||
import {TemplateSchema} from "../templates/schema/index.js";
|
||||
import ActionSchema from "../action-v1.0.min.json";
|
||||
|
||||
let schema: TemplateSchema;
|
||||
|
||||
/**
|
||||
* Returns the action.yml schema, lazily loading and caching it on first access.
|
||||
* The schema defines the structure and validation rules for action manifest files.
|
||||
*/
|
||||
export function getActionSchema(): TemplateSchema {
|
||||
if (schema === undefined) {
|
||||
const json = JSON.stringify(ActionSchema);
|
||||
schema = TemplateSchema.load(new JSONObjectReader(undefined, json));
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import {
|
||||
BasicExpressionToken,
|
||||
MappingToken,
|
||||
ScalarToken,
|
||||
StringToken,
|
||||
TemplateToken
|
||||
} from "../templates/tokens/index.js";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Represents a parsed and converted action.yml file
|
||||
*/
|
||||
export type ActionTemplate = {
|
||||
name: string;
|
||||
description: string;
|
||||
author?: string;
|
||||
inputs?: ActionInputDefinition[];
|
||||
outputs?: ActionOutputDefinition[];
|
||||
runs: ActionRuns;
|
||||
branding?: ActionBranding;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents an input definition from the action.yml inputs section.
|
||||
*/
|
||||
export type ActionInputDefinition = {
|
||||
id: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: ScalarToken;
|
||||
deprecationMessage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents an output definition from the action.yml outputs section.
|
||||
*/
|
||||
export type ActionOutputDefinition = {
|
||||
id: string;
|
||||
description?: string;
|
||||
value?: ScalarToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* Union type representing the different ways an action can be executed.
|
||||
*/
|
||||
export type ActionRuns = ActionRunsComposite | ActionRunsNode | ActionRunsDocker;
|
||||
|
||||
/**
|
||||
* Configuration for composite actions that execute a sequence of steps.
|
||||
*/
|
||||
export type ActionRunsComposite = {
|
||||
using: "composite";
|
||||
steps: Step[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for JavaScript actions that run in Node.js.
|
||||
*/
|
||||
export type ActionRunsNode = {
|
||||
using: "node12" | "node16" | "node20" | "node24";
|
||||
main: string;
|
||||
pre?: string;
|
||||
preIf?: string;
|
||||
post?: string;
|
||||
postIf?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for Docker container actions.
|
||||
*/
|
||||
export type ActionRunsDocker = {
|
||||
using: "docker";
|
||||
image: string;
|
||||
preEntrypoint?: string;
|
||||
preIf?: string;
|
||||
entrypoint?: string;
|
||||
postEntrypoint?: string;
|
||||
postIf?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Branding configuration for displaying the action in the GitHub Marketplace.
|
||||
*/
|
||||
export type ActionBranding = {
|
||||
icon?: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export type ActionTemplateConverterOptions = {
|
||||
/**
|
||||
* The error policy to use when converting the action.
|
||||
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
|
||||
*/
|
||||
errorPolicy?: ErrorPolicy;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a parsed action template token into a typed ActionTemplate
|
||||
*/
|
||||
export function convertActionTemplate(
|
||||
context: TemplateContext,
|
||||
root: TemplateToken,
|
||||
options?: ActionTemplateConverterOptions
|
||||
): ActionTemplate {
|
||||
const result: Partial<ActionTemplate> = {};
|
||||
const errorPolicy = options?.errorPolicy ?? ErrorPolicy.ReturnErrorsOnly;
|
||||
|
||||
// Skip conversion if there are parse errors (unless TryConversion is set)
|
||||
if (context.errors.getErrors().length > 0 && errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
||||
return result as ActionTemplate;
|
||||
}
|
||||
|
||||
if (!isMapping(root)) {
|
||||
context.error(root, new Error("Action must be a mapping"));
|
||||
return result as ActionTemplate;
|
||||
}
|
||||
|
||||
for (const item of root) {
|
||||
const key = item.key.assertString("action key");
|
||||
|
||||
switch (key.value) {
|
||||
case "name":
|
||||
if (isString(item.value)) {
|
||||
result.name = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "description":
|
||||
if (isString(item.value)) {
|
||||
result.description = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "author":
|
||||
if (isString(item.value)) {
|
||||
result.author = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "inputs":
|
||||
result.inputs = convertInputs(context, item.value);
|
||||
break;
|
||||
|
||||
case "outputs":
|
||||
result.outputs = convertOutputs(context, item.value);
|
||||
break;
|
||||
|
||||
case "runs":
|
||||
result.runs = convertRuns(context, item.value);
|
||||
break;
|
||||
|
||||
case "branding":
|
||||
result.branding = convertBranding(context, item.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result as ActionTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the inputs mapping token into an array of ActionInputDefinition objects.
|
||||
*/
|
||||
function convertInputs(context: TemplateContext, token: TemplateToken): ActionInputDefinition[] {
|
||||
const inputs: ActionInputDefinition[] = [];
|
||||
|
||||
if (!isMapping(token)) {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
for (const item of token) {
|
||||
const id = item.key.assertString("input id").value;
|
||||
const input: ActionInputDefinition = {id};
|
||||
|
||||
if (isMapping(item.value)) {
|
||||
for (const prop of item.value) {
|
||||
const propKey = prop.key.assertString("input property").value;
|
||||
|
||||
switch (propKey) {
|
||||
case "description":
|
||||
if (isString(prop.value)) {
|
||||
input.description = prop.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "required":
|
||||
if (isBoolean(prop.value)) {
|
||||
input.required = prop.value.value;
|
||||
} else if (isString(prop.value)) {
|
||||
input.required = prop.value.value === "true";
|
||||
}
|
||||
break;
|
||||
|
||||
case "default":
|
||||
if (isScalar(prop.value)) {
|
||||
input.default = prop.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "deprecationMessage":
|
||||
if (isString(prop.value)) {
|
||||
input.deprecationMessage = prop.value.value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inputs.push(input);
|
||||
}
|
||||
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the outputs mapping token into an array of ActionOutputDefinition objects.
|
||||
*/
|
||||
function convertOutputs(context: TemplateContext, token: TemplateToken): ActionOutputDefinition[] {
|
||||
const outputs: ActionOutputDefinition[] = [];
|
||||
|
||||
if (!isMapping(token)) {
|
||||
return outputs;
|
||||
}
|
||||
|
||||
for (const item of token) {
|
||||
const id = item.key.assertString("output id").value;
|
||||
const output: ActionOutputDefinition = {id};
|
||||
|
||||
if (isMapping(item.value)) {
|
||||
for (const prop of item.value) {
|
||||
const propKey = prop.key.assertString("output property").value;
|
||||
|
||||
switch (propKey) {
|
||||
case "description":
|
||||
if (isString(prop.value)) {
|
||||
output.description = prop.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "value":
|
||||
if (isScalar(prop.value)) {
|
||||
output.value = prop.value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputs.push(output);
|
||||
}
|
||||
|
||||
return outputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the runs mapping token into the appropriate ActionRuns variant based on the 'using' value.
|
||||
*/
|
||||
function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns {
|
||||
if (!isMapping(token)) {
|
||||
return {using: "composite", steps: []};
|
||||
}
|
||||
|
||||
let using: string | undefined;
|
||||
let main: string | undefined;
|
||||
let image: string | undefined;
|
||||
let pre: string | undefined;
|
||||
let preIf: string | undefined;
|
||||
let post: string | undefined;
|
||||
let postIf: string | undefined;
|
||||
let preEntrypoint: string | undefined;
|
||||
let entrypoint: string | undefined;
|
||||
let postEntrypoint: string | undefined;
|
||||
let args: string[] | undefined;
|
||||
let env: Record<string, string> | undefined;
|
||||
let steps: Step[] = [];
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString("runs property").value;
|
||||
|
||||
switch (key) {
|
||||
case "using":
|
||||
if (isString(item.value)) {
|
||||
using = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "main":
|
||||
if (isString(item.value)) {
|
||||
main = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "image":
|
||||
if (isString(item.value)) {
|
||||
image = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "pre":
|
||||
if (isString(item.value)) {
|
||||
pre = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "pre-if":
|
||||
if (isString(item.value)) {
|
||||
preIf = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "post":
|
||||
if (isString(item.value)) {
|
||||
post = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "post-if":
|
||||
if (isString(item.value)) {
|
||||
postIf = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "pre-entrypoint":
|
||||
if (isString(item.value)) {
|
||||
preEntrypoint = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "entrypoint":
|
||||
if (isString(item.value)) {
|
||||
entrypoint = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "post-entrypoint":
|
||||
if (isString(item.value)) {
|
||||
postEntrypoint = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "args":
|
||||
if (isSequence(item.value)) {
|
||||
args = [];
|
||||
for (const arg of item.value) {
|
||||
if (isScalar(arg)) {
|
||||
args.push(arg.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "env":
|
||||
if (isMapping(item.value)) {
|
||||
env = {};
|
||||
for (const envItem of item.value) {
|
||||
const envKey = envItem.key.assertString("env key").value;
|
||||
if (isString(envItem.value)) {
|
||||
env[envKey] = envItem.value.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "steps":
|
||||
steps = convertSteps(context, item.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the type of runs configuration
|
||||
if (using === "composite") {
|
||||
return {using: "composite", steps};
|
||||
} else if (using === "docker" && image) {
|
||||
return {
|
||||
using: "docker",
|
||||
image,
|
||||
preEntrypoint,
|
||||
preIf,
|
||||
entrypoint,
|
||||
postEntrypoint,
|
||||
postIf,
|
||||
args,
|
||||
env
|
||||
};
|
||||
} else if ((using === "node12" || using === "node16" || using === "node20" || using === "node24") && main) {
|
||||
return {
|
||||
using,
|
||||
main,
|
||||
pre,
|
||||
preIf,
|
||||
post,
|
||||
postIf
|
||||
};
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return {using: "composite", steps: []};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a steps sequence token into an array of Step objects for composite actions.
|
||||
*/
|
||||
function convertSteps(context: TemplateContext, token: TemplateToken): Step[] {
|
||||
const steps: Step[] = [];
|
||||
|
||||
if (!isSequence(token)) {
|
||||
return steps;
|
||||
}
|
||||
|
||||
for (const stepToken of token) {
|
||||
if (!isMapping(stepToken)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const step = convertStep(context, stepToken);
|
||||
if (step) {
|
||||
steps.push(step);
|
||||
}
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a single step mapping token into a Step object.
|
||||
* Returns undefined if the step lacks both 'run' and 'uses' properties.
|
||||
*/
|
||||
function convertStep(context: TemplateContext, token: MappingToken): Step | undefined {
|
||||
let id: string | undefined;
|
||||
let name: ScalarToken | undefined;
|
||||
let ifCondition: BasicExpressionToken | undefined;
|
||||
let continueOnError: boolean | ScalarToken | undefined;
|
||||
let env: MappingToken | undefined;
|
||||
let run: ScalarToken | undefined;
|
||||
let uses: StringToken | undefined;
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString("step property").value;
|
||||
|
||||
switch (key) {
|
||||
case "id":
|
||||
if (isString(item.value)) {
|
||||
id = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "name":
|
||||
if (isScalar(item.value)) {
|
||||
name = item.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "if":
|
||||
ifCondition = convertToIfCondition(context, item.value);
|
||||
break;
|
||||
|
||||
case "continue-on-error":
|
||||
if (isBoolean(item.value)) {
|
||||
continueOnError = item.value.value;
|
||||
} else if (isScalar(item.value)) {
|
||||
continueOnError = item.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "env":
|
||||
if (isMapping(item.value)) {
|
||||
env = item.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "run":
|
||||
if (isScalar(item.value)) {
|
||||
run = item.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "uses":
|
||||
if (isString(item.value)) {
|
||||
uses = item.value;
|
||||
}
|
||||
break;
|
||||
|
||||
// Note: shell, working-directory, and with are valid step properties
|
||||
// but not currently tracked in the Step model
|
||||
}
|
||||
}
|
||||
|
||||
// Default if condition to success() like workflow steps
|
||||
const defaultIf = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined);
|
||||
|
||||
// Produce Step type (same as workflow steps)
|
||||
if (run) {
|
||||
return {
|
||||
id: id || "",
|
||||
name,
|
||||
if: ifCondition || defaultIf,
|
||||
"continue-on-error": continueOnError,
|
||||
env,
|
||||
run
|
||||
};
|
||||
} else if (uses) {
|
||||
return {
|
||||
id: id || "",
|
||||
name,
|
||||
if: ifCondition || defaultIf,
|
||||
"continue-on-error": continueOnError,
|
||||
env,
|
||||
uses
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the branding mapping token into an ActionBranding object.
|
||||
*/
|
||||
function convertBranding(context: TemplateContext, token: TemplateToken): ActionBranding {
|
||||
const branding: ActionBranding = {};
|
||||
|
||||
if (!isMapping(token)) {
|
||||
return branding;
|
||||
}
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString("branding property").value;
|
||||
|
||||
switch (key) {
|
||||
case "icon":
|
||||
if (isString(item.value)) {
|
||||
branding.icon = item.value.value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "color":
|
||||
if (isString(item.value)) {
|
||||
branding.color = item.value.value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return branding;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Action parser and schema
|
||||
export {parseAction} from "./action-parser.js";
|
||||
export {getActionSchema} from "./action-schema.js";
|
||||
export {ACTION_ROOT} from "./action-constants.js";
|
||||
|
||||
// Action template types and converter
|
||||
export {
|
||||
ActionTemplate,
|
||||
ActionTemplateConverterOptions,
|
||||
ActionInputDefinition,
|
||||
ActionOutputDefinition,
|
||||
ActionRuns,
|
||||
ActionRunsComposite,
|
||||
ActionRunsNode,
|
||||
ActionRunsDocker,
|
||||
ActionBranding,
|
||||
convertActionTemplate
|
||||
} from "./action-template.js";
|
||||
|
||||
// Re-export Step from workflow-template for convenience
|
||||
export {Step, ActionStep, RunStep} from "../model/workflow-template.js";
|
||||
@@ -2,4 +2,5 @@ export {convertWorkflowTemplate} from "./model/convert.js";
|
||||
export {WorkflowTemplate} from "./model/workflow-template.js";
|
||||
export * from "./templates/tokens/type-guards.js";
|
||||
export {NoOperationTraceWriter, TraceWriter} from "./templates/trace-writer.js";
|
||||
export {TemplateParseResult} from "./templates/template-parse-result.js";
|
||||
export {parseWorkflow, ParseWorkflowResult} from "./workflows/workflow-parser.js";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import {TemplateContext} from "./template-context.js";
|
||||
import {TemplateToken} from "./tokens/template-token.js";
|
||||
|
||||
/**
|
||||
* Result of parsing a template file (workflow or action)
|
||||
*/
|
||||
export interface TemplateParseResult {
|
||||
context: TemplateContext;
|
||||
value: TemplateToken | undefined;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import {TemplateParseResult} from "../templates/template-parse-result.js";
|
||||
import {TemplateContext, TemplateValidationErrors} from "../templates/template-context.js";
|
||||
import * as templateReader from "../templates/template-reader.js";
|
||||
import {TemplateToken} from "../templates/tokens/template-token.js";
|
||||
import {TraceWriter} from "../templates/trace-writer.js";
|
||||
import {File} from "./file.js";
|
||||
import {WORKFLOW_ROOT} from "./workflow-constants.js";
|
||||
import {getWorkflowSchema} from "./workflow-schema.js";
|
||||
import {YamlObjectReader} from "./yaml-object-reader.js";
|
||||
export interface ParseWorkflowResult {
|
||||
context: TemplateContext;
|
||||
value: TemplateToken | undefined;
|
||||
}
|
||||
|
||||
export function parseWorkflow(entryFile: File, trace: TraceWriter): ParseWorkflowResult;
|
||||
export function parseWorkflow(entryFile: File, context: TemplateContext): ParseWorkflowResult;
|
||||
export function parseWorkflow(entryFile: File, contextOrTrace: TraceWriter | TemplateContext): ParseWorkflowResult {
|
||||
/** @deprecated Use TemplateParseResult instead */
|
||||
export type ParseWorkflowResult = TemplateParseResult;
|
||||
|
||||
/**
|
||||
* Parses a GitHub Actions workflow YAML file and returns the parsed template result.
|
||||
* Validates the workflow against the workflow schema and reports any errors.
|
||||
*/
|
||||
export function parseWorkflow(entryFile: File, trace: TraceWriter): TemplateParseResult;
|
||||
export function parseWorkflow(entryFile: File, context: TemplateContext): TemplateParseResult;
|
||||
export function parseWorkflow(entryFile: File, contextOrTrace: TraceWriter | TemplateContext): TemplateParseResult {
|
||||
const context =
|
||||
contextOrTrace instanceof TemplateContext
|
||||
? contextOrTrace
|
||||
@@ -33,7 +36,7 @@ export function parseWorkflow(entryFile: File, contextOrTrace: TraceWriter | Tem
|
||||
}
|
||||
const result = templateReader.readTemplate(context, WORKFLOW_ROOT, reader, fileId);
|
||||
|
||||
return <ParseWorkflowResult>{
|
||||
return <TemplateParseResult>{
|
||||
context,
|
||||
value: result
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user