Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 952dc89b78 | |||
| 2934e36944 | |||
| 8d2c24d7f5 | |||
| 4181cb3c90 | |||
| 78ea3ba17f | |||
| 4cf3365c68 | |||
| 1a63ee9de6 | |||
| 108b8c2766 | |||
| e20dbae803 | |||
| 69b383af3d | |||
| 4429c41275 |
@@ -12,15 +12,19 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 16.15
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.15
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
- run: npm ci --engine-strict
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: npm run format-check -ws
|
||||
@@ -33,10 +37,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 16.15
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.15
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.x
|
||||
node-version: 22.x
|
||||
cache: "npm"
|
||||
scope: '@actions'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.23",
|
||||
"version": "0.3.24",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -44,7 +44,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.23",
|
||||
"version": "0.3.24",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -43,8 +43,8 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.23",
|
||||
"@actions/workflow-parser": "^0.3.23",
|
||||
"@actions/languageservice": "^0.3.24",
|
||||
"@actions/workflow-parser": "^0.3.24",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -52,7 +52,7 @@
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
|
||||
describe("contextProviders", () => {
|
||||
const mockCache = new TTLCache();
|
||||
const mockRepo: RepositoryContext = {
|
||||
id: 123,
|
||||
owner: "test-owner",
|
||||
name: "test-repo",
|
||||
organizationOwned: true,
|
||||
workspaceUri: "file:///workspace"
|
||||
};
|
||||
const mockWorkflowContext: WorkflowContext = {
|
||||
uri: "test.yaml",
|
||||
template: undefined
|
||||
};
|
||||
|
||||
describe("when client is undefined", () => {
|
||||
it("should return incomplete context for secrets", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should return incomplete context for vars", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should preserve defaultContext and mark as incomplete for secrets", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const defaultContext = new DescriptionDictionary();
|
||||
defaultContext.add("EXISTING_SECRET", new data.StringData("test"));
|
||||
|
||||
const result = await config.getContext("secrets", defaultContext, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBe(defaultContext);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
expect((result as DescriptionDictionary).get("EXISTING_SECRET")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return undefined for other contexts like steps", async () => {
|
||||
const config = contextProviders(undefined, mockRepo, mockCache);
|
||||
const result = await config.getContext("steps", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when both client and repo are undefined", () => {
|
||||
it("should return incomplete context for secrets", async () => {
|
||||
const config = contextProviders(undefined, undefined, mockCache);
|
||||
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should return incomplete context for vars", async () => {
|
||||
const config = contextProviders(undefined, undefined, mockCache);
|
||||
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
|
||||
|
||||
expect(result).toBeInstanceOf(DescriptionDictionary);
|
||||
expect((result as DescriptionDictionary).complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,18 @@ export function contextProviders(
|
||||
cache: TTLCache
|
||||
): ContextProviderConfig {
|
||||
if (!repo || !client) {
|
||||
return {getContext: () => Promise.resolve(undefined)};
|
||||
// When GitHub client/repo is unavailable, return an incomplete dictionary
|
||||
// to avoid false "Context access might be invalid" warnings
|
||||
return {
|
||||
getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => {
|
||||
if (name === "secrets" || name === "vars") {
|
||||
const context = defaultContext || new DescriptionDictionary();
|
||||
context.complete = false;
|
||||
return Promise.resolve(context);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getContext = async (
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function getSecrets(
|
||||
if (isString(x.value)) {
|
||||
environmentName = x.value.value;
|
||||
} else {
|
||||
// this means we have a dynamic enviornment, in those situations we
|
||||
// this means we have a dynamic environment, in those situations we
|
||||
// want to make sure we skip doing secret validation
|
||||
secretsContext.complete = false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
@@ -63,6 +63,43 @@ it("returns default context when job is undefined", async () => {
|
||||
expect(stepsContext).toEqual(defaultContext);
|
||||
});
|
||||
|
||||
it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
const workflowContext = await createWorkflowContext(workflow, "build");
|
||||
const defaultContext = getDefaultStepsContext(workflowContext);
|
||||
|
||||
const stepsContext = await getStepsContext(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
new TTLCache(),
|
||||
defaultContext,
|
||||
workflowContext
|
||||
);
|
||||
|
||||
// Get the step context
|
||||
const stepContext = stepsContext?.get("cache-primes");
|
||||
expect(stepContext).toBeDefined();
|
||||
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);
|
||||
|
||||
// Outputs should be marked incomplete to allow dynamic outputs
|
||||
const outputsDict = outputs as DescriptionDictionary;
|
||||
expect(outputsDict.complete).toBe(false);
|
||||
|
||||
// Known outputs from action.yml should be present
|
||||
expect(outputsDict.get("cache-hit")).toBeDefined();
|
||||
});
|
||||
|
||||
it("adds action outputs", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
@@ -83,17 +120,22 @@ it("adds action outputs", async () => {
|
||||
);
|
||||
expect(stepsContext).toBeDefined();
|
||||
|
||||
// Create expected outputs dict with complete = false
|
||||
// (actions can have dynamic outputs beyond what's declared in action.yml)
|
||||
const expectedOutputs = new DescriptionDictionary({
|
||||
key: "cache-hit",
|
||||
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
|
||||
description: "A boolean value to indicate an exact match was found for the primary key"
|
||||
});
|
||||
expectedOutputs.complete = false;
|
||||
|
||||
expect(stepsContext).toEqual(
|
||||
new DescriptionDictionary({
|
||||
key: "cache-primes",
|
||||
value: new DescriptionDictionary(
|
||||
{
|
||||
key: "outputs",
|
||||
value: new DescriptionDictionary({
|
||||
key: "cache-hit",
|
||||
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
|
||||
description: "A boolean value to indicate an exact match was found for the primary key"
|
||||
})
|
||||
value: expectedOutputs
|
||||
},
|
||||
{
|
||||
key: "conclusion",
|
||||
|
||||
@@ -58,6 +58,8 @@ export async function getStepsContext(
|
||||
continue;
|
||||
}
|
||||
const outputsDict = new DescriptionDictionary();
|
||||
// Actions can have dynamic outputs beyond what's declared in action.yml
|
||||
outputsDict.complete = false;
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
outputsDict.add(key, new data.StringData(value.description), value.description);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export async function getVariables(
|
||||
return secretsContext;
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
|
||||
let environmentName: string | undefined;
|
||||
if (workflowContext?.job?.environment) {
|
||||
if (isString(workflowContext.job.environment)) {
|
||||
@@ -35,14 +37,19 @@ export async function getVariables(
|
||||
if (isString(x.key) && x.key.value === "name") {
|
||||
if (isString(x.value)) {
|
||||
environmentName = x.value.value;
|
||||
} else {
|
||||
// this means we have a dynamic environment, in those situations we want to skip validation
|
||||
variablesContext.complete = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if the expression is something like environment: ${{ ... }} then we want to skip validation
|
||||
variablesContext.complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
try {
|
||||
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.23",
|
||||
"version": "0.3.24",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -47,15 +47,15 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.23",
|
||||
"@actions/workflow-parser": "^0.3.23",
|
||||
"@actions/expressions": "^0.3.24",
|
||||
"@actions/workflow-parser": "^0.3.24",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
"yaml": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -299,7 +299,16 @@ jobs:
|
||||
"on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo";
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual(["arch", "name", "os", "temp", "tool_cache"]);
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"arch",
|
||||
"debug",
|
||||
"environment",
|
||||
"name",
|
||||
"os",
|
||||
"temp",
|
||||
"tool_cache",
|
||||
"workspace"
|
||||
]);
|
||||
});
|
||||
|
||||
describe("job if", () => {
|
||||
@@ -861,7 +870,7 @@ jobs:
|
||||
});
|
||||
|
||||
describe("strategy context", () => {
|
||||
it("strategy is not suggested when outside of a matrix job", async () => {
|
||||
it("strategy is suggested even when no strategy defined", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -875,7 +884,7 @@ jobs:
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).not.toContain("strategy");
|
||||
expect(result.map(x => x.label)).toContain("strategy");
|
||||
});
|
||||
|
||||
it("strategy is suggested within a matrix job", async () => {
|
||||
@@ -922,7 +931,7 @@ jobs:
|
||||
});
|
||||
|
||||
describe("matrix context", () => {
|
||||
it("matrix is not suggested when outside of a matrix job", async () => {
|
||||
it("matrix is suggested even when no strategy defined", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -936,7 +945,7 @@ jobs:
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).not.toContain("strategy");
|
||||
expect(result.map(x => x.label)).toContain("matrix");
|
||||
});
|
||||
|
||||
it("matrix is suggested within a matrix job", async () => {
|
||||
@@ -1123,10 +1132,12 @@ jobs:
|
||||
"github",
|
||||
"inputs",
|
||||
"job",
|
||||
"matrix",
|
||||
"needs",
|
||||
"runner",
|
||||
"secrets",
|
||||
"steps",
|
||||
"strategy",
|
||||
"vars",
|
||||
"contains",
|
||||
"endsWith",
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import {complete} from "./complete";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("Issue #81 - multi-line if expression completion", () => {
|
||||
it("should complete in block scalar if with | (exact position)", async () => {
|
||||
// Exact reproduction from issue - cursor after "github." in block scalar
|
||||
const input = `on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
github.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
// Line 5 (0-indexed) = " github.", character 13 = after the dot
|
||||
const pos = {line: 5, character: 13};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
expect(result.map(x => x.label)).toContain("actor");
|
||||
});
|
||||
|
||||
it("should complete in block scalar if with > (exact position)", async () => {
|
||||
const input = `on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: >
|
||||
github.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
const pos = {line: 5, character: 13};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete in block scalar with multiple lines", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
// Skip 1 to skip the `|` block scalar indicator (same character as cursor marker)
|
||||
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete step if in block scalar", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo
|
||||
if: |
|
||||
github.
|
||||
`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
// Line 7 = " github.", character 15 = after the dot (8 spaces + 7 chars)
|
||||
const pos = {line: 7, character: 15};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete in block scalar with ${{ expression markers", async () => {
|
||||
// This case works because transform() skips lines with ${{
|
||||
// Note: Using explicit position because | appears in multiple places (block scalar, ||, cursor)
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
\${{
|
||||
github.ref == 'refs/heads/main' ||
|
||||
github.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
|
||||
// Line 6 = " github." = 8 spaces + 7 chars = 15 chars, cursor after dot is at char 15
|
||||
const pos = {line: 6, character: 15};
|
||||
|
||||
const result = await complete(doc, pos, {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("ref");
|
||||
expect(result.map(x => x.label)).toContain("ref_name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases for getOffsetInContent", () => {
|
||||
it("should complete in single-line if (not block scalar)", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete on third content line of block scalar", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
|
||||
it("should complete when block scalar has empty first line", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: |
|
||||
|
||||
github.|
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input, 1), {});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.map(x => x.label)).toContain("event");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
||||
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
@@ -19,7 +20,6 @@ import {isPotentiallyExpression} from "./utils/expression-detection";
|
||||
import {findToken} from "./utils/find-token";
|
||||
import {guessIndentation} from "./utils/indentation-guesser";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {getRelCharOffset} from "./utils/rel-char-pos";
|
||||
import {isPlaceholder, transform} from "./utils/transform";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||
import {Value, ValueProviderConfig} from "./value-providers/config";
|
||||
@@ -238,12 +238,12 @@ function getExpressionCompletionItems(
|
||||
currentInput = stringToken.source || stringToken.value;
|
||||
}
|
||||
|
||||
const relCharOffset = getRelCharOffset(token.range, currentInput, pos);
|
||||
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
|
||||
const cursorOffset = getOffsetInContent(token.range, currentInput, pos);
|
||||
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
||||
|
||||
try {
|
||||
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
|
||||
mapExpressionCompletionItem(item, currentInput[relCharOffset])
|
||||
mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
||||
);
|
||||
} catch (e) {
|
||||
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
||||
@@ -274,3 +274,50 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo
|
||||
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a document position to an offset within the token's content string.
|
||||
*/
|
||||
function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: Position): number {
|
||||
const range = mapRange(tokenRange);
|
||||
|
||||
if (range.start.line === range.end.line) {
|
||||
// Single-line example:
|
||||
// if: github.ref == 'main'
|
||||
// ^8 ^15 (cursor)
|
||||
// currentInput = "github.ref == 'main'"
|
||||
// offset = 15 - 8 = 7
|
||||
return pos.character - range.start.character;
|
||||
}
|
||||
|
||||
// Multi-line example:
|
||||
// if: | <- line 3 (range.start.line)
|
||||
// first line <- line 4, content line 0
|
||||
// second line <- line 5, content line 1
|
||||
// github. <- line 6, content line 2, cursor at index 11
|
||||
// ^11 (cursor)
|
||||
//
|
||||
// currentInput = " first line\n second line\n github."
|
||||
// ^0 ^15 ^32 ^43
|
||||
|
||||
// Line index within content.
|
||||
// From the example:
|
||||
// lineIndexWithinContent = pos.line - range.start.line - 1
|
||||
// = 6 - 3 - 1 = 2
|
||||
const lineIndexWithinContent = pos.line - range.start.line - 1;
|
||||
|
||||
// Length of content before current line.
|
||||
// From the example:
|
||||
// lengthOfContentBeforeCurrentLine => 14 + 1 = 15 (after first iteration)
|
||||
// => 31 + 1 = 32 (after second iteration)
|
||||
let lengthOfContentBeforeCurrentLine = 0;
|
||||
for (let i = 0; i < lineIndexWithinContent; i++) {
|
||||
lengthOfContentBeforeCurrentLine = currentInput.indexOf("\n", lengthOfContentBeforeCurrentLine) + 1;
|
||||
}
|
||||
|
||||
// Final offset within content.
|
||||
// From the example:
|
||||
// finalOffset = lengthOfContentBeforeCurrentLine + pos.character
|
||||
// = 32 + 11 = 43
|
||||
return lengthOfContentBeforeCurrentLine + pos.character;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {getContext, Mode} from "./default";
|
||||
|
||||
describe("getContext", () => {
|
||||
const emptyWorkflowContext: WorkflowContext = {
|
||||
uri: "test.yaml",
|
||||
template: undefined
|
||||
};
|
||||
|
||||
describe("when no contextProviderConfig is provided", () => {
|
||||
it("should mark secrets context as incomplete", async () => {
|
||||
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||
expect(secretsContext).toBeDefined();
|
||||
expect(secretsContext.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should mark vars context as incomplete", async () => {
|
||||
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||
expect(varsContext).toBeDefined();
|
||||
expect(varsContext.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should not mark other contexts as incomplete", async () => {
|
||||
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const envContext = result.get("env") as DescriptionDictionary;
|
||||
const githubContext = result.get("github") as DescriptionDictionary;
|
||||
|
||||
// These contexts are derived from the workflow file, so they can be complete
|
||||
expect(envContext).toBeDefined();
|
||||
expect(envContext.complete).toBe(true);
|
||||
expect(githubContext).toBeDefined();
|
||||
expect(githubContext.complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when contextProviderConfig returns a value", () => {
|
||||
it("should use the provided context for secrets", async () => {
|
||||
const providedContext = new DescriptionDictionary();
|
||||
providedContext.complete = true; // Provider fetched from API, so it's complete
|
||||
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(providedContext)
|
||||
};
|
||||
|
||||
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets");
|
||||
expect(secretsContext).toBe(providedContext);
|
||||
expect((secretsContext as DescriptionDictionary).complete).toBe(true);
|
||||
});
|
||||
|
||||
it("should use the provided context for vars", async () => {
|
||||
const providedContext = new DescriptionDictionary();
|
||||
providedContext.complete = true;
|
||||
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(providedContext)
|
||||
};
|
||||
|
||||
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars");
|
||||
expect(varsContext).toBe(providedContext);
|
||||
expect((varsContext as DescriptionDictionary).complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when contextProviderConfig returns undefined", () => {
|
||||
it("should mark secrets as incomplete", async () => {
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(undefined)
|
||||
};
|
||||
|
||||
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||
expect(secretsContext.complete).toBe(false);
|
||||
});
|
||||
|
||||
it("should mark vars as incomplete", async () => {
|
||||
const config = {
|
||||
getContext: () => Promise.resolve(undefined)
|
||||
};
|
||||
|
||||
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||
|
||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||
expect(varsContext.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,15 +32,24 @@ export async function getContext(
|
||||
): Promise<DescriptionDictionary> {
|
||||
const context = new DescriptionDictionary();
|
||||
|
||||
const filteredNames = filterContextNames(names, workflowContext);
|
||||
for (const contextName of filteredNames) {
|
||||
// All context names are valid - strategy and matrix are always available
|
||||
// (with default values when no strategy block is defined)
|
||||
for (const contextName of names) {
|
||||
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
|
||||
if (value.kind === Kind.Null) {
|
||||
context.add(contextName, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
value = (await config?.getContext(contextName, value, workflowContext, mode)) || value;
|
||||
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
|
||||
if (remoteValue) {
|
||||
value = remoteValue;
|
||||
} else if (contextName === "secrets" || contextName === "vars") {
|
||||
// Without a context provider to fetch remote secrets/vars, we can't know
|
||||
// what values exist, so mark the context as incomplete to avoid false
|
||||
// "Context access might be invalid" warnings
|
||||
value.complete = false;
|
||||
}
|
||||
|
||||
context.add(contextName, value, getDescription(RootContext, contextName));
|
||||
}
|
||||
@@ -74,11 +83,14 @@ function getDefaultContext(name: string, workflowContext: WorkflowContext, mode:
|
||||
|
||||
case "runner":
|
||||
return objectToDictionary({
|
||||
os: "Linux",
|
||||
arch: "X64",
|
||||
debug: "1",
|
||||
environment: "github-hosted",
|
||||
name: "GitHub Actions 2",
|
||||
os: "Linux",
|
||||
temp: "/home/runner/work/_temp",
|
||||
tool_cache: "/opt/hostedtoolcache",
|
||||
temp: "/home/runner/work/_temp"
|
||||
workspace: "/home/runner/work/repo"
|
||||
});
|
||||
|
||||
case "secrets":
|
||||
@@ -103,18 +115,3 @@ function objectToDictionary(object: {[key: string]: string}): DescriptionDiction
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
|
||||
return contextNames.filter(name => {
|
||||
switch (name) {
|
||||
case "matrix":
|
||||
case "strategy":
|
||||
return hasStrategy(workflowContext);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function hasStrategy(workflowContext: WorkflowContext): boolean {
|
||||
return workflowContext.job?.strategy !== undefined || workflowContext.reusableWorkflowJob?.strategy !== undefined;
|
||||
}
|
||||
|
||||
@@ -239,7 +239,13 @@
|
||||
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software).\""
|
||||
},
|
||||
"debug": {
|
||||
"description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `1`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
|
||||
"description": "This is set only if [`ACTIONS_STEP_DEBUG`](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `\"1\"`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
|
||||
},
|
||||
"environment": {
|
||||
"description": "The environment of the runner executing the job. Possible values are `github-hosted` for GitHub-hosted runners, or `self-hosted` for self-hosted runners."
|
||||
},
|
||||
"workspace": {
|
||||
"description": "The runner-specific working directory path for the job."
|
||||
}
|
||||
},
|
||||
"strategy": {
|
||||
|
||||
@@ -64,7 +64,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job).toBeUndefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
expect(context).toEqual(new data.Null());
|
||||
});
|
||||
|
||||
it("strategy not defined", () => {
|
||||
@@ -73,7 +73,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job!.strategy).toBeUndefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
expect(context).toEqual(new data.Null());
|
||||
});
|
||||
|
||||
it("strategy is not a mapping token", () => {
|
||||
@@ -81,7 +81,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job!.strategy).toBeDefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
expect(context).toEqual(new data.Null());
|
||||
});
|
||||
|
||||
it("matrix is not defined", () => {
|
||||
|
||||
@@ -10,7 +10,8 @@ export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
|
||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||
if (!strategy || !isMapping(strategy)) {
|
||||
return new DescriptionDictionary();
|
||||
// No strategy defined - matrix is null at runtime (not empty object)
|
||||
return new data.Null();
|
||||
}
|
||||
|
||||
const matrix = strategy.find("matrix");
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {getStepsContext} from "./steps";
|
||||
|
||||
function createWorkflowContext(stepIds: string[], currentStepId?: string): WorkflowContext {
|
||||
return {
|
||||
job: {
|
||||
steps: stepIds.map(id => ({id}))
|
||||
},
|
||||
step: currentStepId ? {id: currentStepId} : undefined
|
||||
} as WorkflowContext;
|
||||
}
|
||||
|
||||
describe("steps context", () => {
|
||||
it("returns empty dictionary when no job", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
const context = getStepsContext(workflowContext);
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty dictionary when no steps", () => {
|
||||
const workflowContext = {job: {}} as WorkflowContext;
|
||||
const context = getStepsContext(workflowContext);
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("includes steps with user-defined ids", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "step-b"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("step-b")).toBeDefined();
|
||||
});
|
||||
|
||||
it("excludes generated step ids (starting with __)", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "__generated"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("__generated")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("excludes current step and later steps", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "step-b", "step-c"], "step-b");
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("step-b")).toBeUndefined();
|
||||
expect(context.get("step-c")).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("step outputs", () => {
|
||||
it("outputs is a dictionary, not null", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
const stepContext = context.get("step-a");
|
||||
expect(stepContext).toBeDefined();
|
||||
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
||||
|
||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||
expect(outputs).toBeDefined();
|
||||
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
||||
});
|
||||
|
||||
it("outputs is marked incomplete to allow dynamic outputs", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
const stepContext = context.get("step-a") as DescriptionDictionary;
|
||||
const outputs = stepContext.get("outputs") as DescriptionDictionary;
|
||||
|
||||
// Outputs should be incomplete since we can't know what outputs a step will produce
|
||||
expect(outputs.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,10 @@ function stepContext(): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context
|
||||
const d = new DescriptionDictionary();
|
||||
|
||||
d.add("outputs", new data.Null(), getDescription("steps", "outputs"));
|
||||
// Step outputs are dynamic - actions can generate outputs based on their inputs
|
||||
const outputs = new DescriptionDictionary();
|
||||
outputs.complete = false;
|
||||
d.add("outputs", outputs, getDescription("steps", "outputs"));
|
||||
|
||||
// Can be "success", "failure", "cancelled", or "skipped"
|
||||
d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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";
|
||||
import {getStrategyContext} from "./strategy";
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,24 @@ import {isMapping, isScalar, isString} from "@actions/workflow-parser";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {scalarToData} from "../utils/scalar-to-data";
|
||||
|
||||
// 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: new data.Null()};
|
||||
return {key, value: DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]};
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -31,7 +40,8 @@ export function getStrategyContext(workflowContext: WorkflowContext): Descriptio
|
||||
|
||||
for (const key of keys) {
|
||||
if (!strategyContext.get(key)) {
|
||||
strategyContext.add(key, new data.Null());
|
||||
// Use default value for missing properties
|
||||
strategyContext.add(key, DEFAULT_STRATEGY[key as keyof typeof DEFAULT_STRATEGY]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
||||
import {Position} from "vscode-languageserver-textdocument";
|
||||
import {mapRange} from "./range";
|
||||
|
||||
export function getRelCharOffset(tokenRange: TokenRange, currentInput: string, pos: Position): number {
|
||||
const range = mapRange(tokenRange);
|
||||
if (range.start.line !== range.end.line) {
|
||||
const lines = currentInput.split("\n");
|
||||
const lineDiff = pos.line - range.start.line - 1;
|
||||
const linesBeforeCusor = lines.slice(0, lineDiff);
|
||||
return linesBeforeCusor.join("\n").length + pos.character + 1;
|
||||
} else {
|
||||
return pos.character - range.start.character;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {validate} from "./validate";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("validate concurrency deadlock", () => {
|
||||
describe("should error on matching concurrency groups", () => {
|
||||
it("simple string match", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
|
||||
// Workflow-level warning
|
||||
expect(concurrencyErrors[0]).toMatchObject({
|
||||
message: "Concurrency group 'test' is also used by job 'job1'. This will cause a deadlock.",
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
|
||||
// Job-level warning
|
||||
expect(concurrencyErrors[1]).toMatchObject({
|
||||
message: "Concurrency group 'test' is also defined at the workflow level. This will cause a deadlock.",
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
});
|
||||
|
||||
it("workflow mapping form, job string form", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: my-group
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: my-group
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
expect(concurrencyErrors[0].message).toContain("my-group");
|
||||
expect(concurrencyErrors[0].message).toContain("deploy");
|
||||
});
|
||||
|
||||
it("workflow string form, job mapping form", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: deploy-group
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: deploy-group
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
expect(concurrencyErrors[0].message).toContain("deploy-group");
|
||||
});
|
||||
|
||||
it("both mapping forms", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: shared
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: shared
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("multiple jobs with matching concurrency", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: shared
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: shared
|
||||
steps:
|
||||
- run: echo hi
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: shared
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
// Should have 2 warnings per job (workflow + job) = 4 total, but workflow is only warned once per match
|
||||
// Actually: 1 workflow warning per matching job + 1 job warning per matching job = 4 total
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("should not warn", () => {
|
||||
it("different concurrency groups", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: workflow-group
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: job-group
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("workflow concurrency is an expression", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: \${{ github.ref }}
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("job concurrency is an expression", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: \${{ github.ref }}
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no workflow-level concurrency", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no job-level concurrency", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("case sensitive - different case is different group", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: Test
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("workflow concurrency group in mapping is an expression", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: \${{ github.ref }}
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency: test
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const concurrencyErrors = result.filter(d => d.message.includes("deadlock"));
|
||||
expect(concurrencyErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -681,7 +681,8 @@ jobs:
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).not.toEqual([]);
|
||||
// Strategy context is always available with default values
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("invalid strategy property", async () => {
|
||||
@@ -996,22 +997,8 @@ jobs:
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
message: "Context access might be invalid: matrix",
|
||||
range: {
|
||||
end: {
|
||||
character: 36,
|
||||
line: 8
|
||||
},
|
||||
start: {
|
||||
character: 18,
|
||||
line: 8
|
||||
}
|
||||
},
|
||||
severity: DiagnosticSeverity.Warning
|
||||
}
|
||||
]);
|
||||
// Matrix is null when no strategy is defined, accessing properties on null is valid
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("basic matrix", async () => {
|
||||
@@ -1609,6 +1596,48 @@ jobs:
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows runner.environment context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: runner.environment == 'github-hosted'
|
||||
run: echo hello`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows runner.debug context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: runner.debug == '1'
|
||||
run: echo hello`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows runner.workspace context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: runner.workspace != ''
|
||||
run: echo hello`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows env context", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Test validation behavior when no context providers are configured.
|
||||
*
|
||||
* When contextProviderConfig is not provided (or returns incomplete data),
|
||||
* we should skip validation for secrets/vars rather than showing false
|
||||
* positive "Context access might be invalid" warnings.
|
||||
*
|
||||
* This is important for offline/disconnected scenarios where API calls
|
||||
* to fetch secrets/vars are not possible.
|
||||
*/
|
||||
|
||||
import {validate} from "./validate";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("validation without context providers", () => {
|
||||
describe("secrets context", () => {
|
||||
it("should not warn on secrets.GITHUB_TOKEN", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "test"
|
||||
env:
|
||||
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on custom secrets when no provider configured", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "test"
|
||||
env:
|
||||
API_KEY: \${{ secrets.MY_API_KEY }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on secrets with environment", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- run: echo "test"
|
||||
env:
|
||||
API_KEY: \${{ secrets.API_KEY }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vars context", () => {
|
||||
it("should not warn on vars when no provider configured", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "\${{ vars.ENVIRONMENT }}"
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on vars with environment", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- run: echo "\${{ vars.API_URL }}"
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not warn on vars with fallback pattern", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "\${{ vars.OPTIONAL_VAR || 'default-value' }}"
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined secrets and vars", () => {
|
||||
it("should not warn on workflow using both secrets and vars", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- run: |
|
||||
echo "Deploying to \${{ vars.API_URL }}"
|
||||
echo "Using region \${{ vars.AWS_REGION }}"
|
||||
env:
|
||||
API_KEY: \${{ secrets.API_KEY }}
|
||||
AWS_SECRET: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -385,4 +385,31 @@ jobs:
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow_dispatch", () => {
|
||||
it("allows empty string in choice options", async () => {
|
||||
const result = await validate(
|
||||
createDocument(
|
||||
"wf.yaml",
|
||||
`on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
plugin-name:
|
||||
description: Specific plugin to build
|
||||
type: choice
|
||||
options:
|
||||
- ''
|
||||
- foo
|
||||
- bar
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo`
|
||||
)
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Lexer, Parser, data} from "@actions/expressions";
|
||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
|
||||
import {ParseWorkflowResult, 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";
|
||||
@@ -209,6 +209,9 @@ async function additionalValidations(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate concurrency deadlock between workflow and job levels
|
||||
validateConcurrencyDeadlock(diagnostics, template);
|
||||
}
|
||||
|
||||
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
|
||||
@@ -712,3 +715,71 @@ async function validateExpression(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that workflow-level and job-level concurrency groups don't match,
|
||||
* which would cause a deadlock at runtime.
|
||||
*/
|
||||
function validateConcurrencyDeadlock(diagnostics: Diagnostic[], template: WorkflowTemplate): void {
|
||||
const workflowGroup = getStaticConcurrencyGroup(template.concurrency);
|
||||
if (!workflowGroup) {
|
||||
return; // No workflow-level concurrency or it's an expression
|
||||
}
|
||||
|
||||
for (const job of template.jobs || []) {
|
||||
if (!job.concurrency) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const jobGroup = getStaticConcurrencyGroup(job.concurrency);
|
||||
if (!jobGroup) {
|
||||
continue; // Job concurrency is an expression
|
||||
}
|
||||
|
||||
if (workflowGroup.value === jobGroup.value) {
|
||||
// Error on workflow-level concurrency
|
||||
if (template.concurrency.range) {
|
||||
diagnostics.push({
|
||||
message: `Concurrency group '${workflowGroup.value}' is also used by job '${job.id.value}'. This will cause a deadlock.`,
|
||||
range: mapRange(template.concurrency.range),
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
}
|
||||
|
||||
// Error on job-level concurrency
|
||||
if (job.concurrency.range) {
|
||||
diagnostics.push({
|
||||
message: `Concurrency group '${jobGroup.value}' is also defined at the workflow level. This will cause a deadlock.`,
|
||||
range: mapRange(job.concurrency.range),
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the static concurrency group name from a concurrency token.
|
||||
* Returns undefined if the token is an expression or doesn't have a static group.
|
||||
*/
|
||||
function getStaticConcurrencyGroup(token: TemplateToken | undefined): StringToken | undefined {
|
||||
if (!token || token.isExpression) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Simple string form: concurrency: "test"
|
||||
if (isString(token)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// Mapping form: concurrency: { group: "test", cancel-in-progress: true }
|
||||
if (isMapping(token)) {
|
||||
for (const pair of token) {
|
||||
if (isString(pair.key) && pair.key.value === "group" && isString(pair.value) && !pair.value.isExpression) {
|
||||
return pair.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import {validate} from "./validate";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("YAML anchors and aliases", () => {
|
||||
it("should handle anchors and aliases in env", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env: &env
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env: *env
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle multiple aliases to the same anchor", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
env: &shared
|
||||
SHARED: true
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
job3:
|
||||
runs-on: ubuntu-latest
|
||||
env: *shared
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle anchors in matrix strategy", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include: &matrix-include
|
||||
- os: ubuntu-latest
|
||||
node: 18
|
||||
- os: windows-latest
|
||||
node: 20
|
||||
steps:
|
||||
- run: exit 0
|
||||
test2:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include: *matrix-include
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle anchors in steps", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- &checkout
|
||||
uses: actions/checkout@v4
|
||||
- run: npm test
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- *checkout
|
||||
- run: npm run deploy
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle scalar anchors", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: &runner ubuntu-latest
|
||||
steps:
|
||||
- run: exit 0
|
||||
test:
|
||||
runs-on: *runner
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should work without anchors (control test)", async () => {
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ENV1: env1
|
||||
ENV2: env2
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle circular aliases without hanging", async () => {
|
||||
// This is an invalid use case (alias referencing parent) but should not hang
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env: &myenv
|
||||
FOO: bar
|
||||
nested: *myenv
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
// Should complete without hanging - circular portion is silently ignored
|
||||
// which may cause downstream validation errors, but that's acceptable
|
||||
const result = await validate(doc);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle undefined alias references", async () => {
|
||||
// Reference to non-existent anchor - yaml library should report error
|
||||
const doc = createDocument(
|
||||
"wf.yaml",
|
||||
`
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env: *nonexistent
|
||||
steps:
|
||||
- run: exit 0
|
||||
`
|
||||
);
|
||||
const result = await validate(doc);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.23"
|
||||
"version": "0.3.24"
|
||||
}
|
||||
Generated
+13
-13
@@ -135,7 +135,7 @@
|
||||
},
|
||||
"expressions": {
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.23",
|
||||
"version": "0.3.24",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -151,7 +151,7 @@
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"expressions/node_modules/@eslint/eslintrc": {
|
||||
@@ -395,11 +395,11 @@
|
||||
},
|
||||
"languageserver": {
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.23",
|
||||
"version": "0.3.24",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.23",
|
||||
"@actions/workflow-parser": "^0.3.23",
|
||||
"@actions/languageservice": "^0.3.24",
|
||||
"@actions/workflow-parser": "^0.3.24",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -421,7 +421,7 @@
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"languageserver/node_modules/@eslint/eslintrc": {
|
||||
@@ -921,11 +921,11 @@
|
||||
},
|
||||
"languageservice": {
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.23",
|
||||
"version": "0.3.24",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.23",
|
||||
"@actions/workflow-parser": "^0.3.23",
|
||||
"@actions/expressions": "^0.3.24",
|
||||
"@actions/workflow-parser": "^0.3.24",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
@@ -947,7 +947,7 @@
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"languageservice/node_modules/@eslint/eslintrc": {
|
||||
@@ -12834,10 +12834,10 @@
|
||||
},
|
||||
"workflow-parser": {
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.23",
|
||||
"version": "0.3.24",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.23",
|
||||
"@actions/expressions": "^0.3.24",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
@@ -12855,7 +12855,7 @@
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.23",
|
||||
"version": "0.3.24",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -48,12 +48,12 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.23",
|
||||
"@actions/expressions": "^0.3.24",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -1539,7 +1539,7 @@
|
||||
},
|
||||
"default": "workflow-dispatch-input-default",
|
||||
"options": {
|
||||
"type": "sequence-of-non-empty-string",
|
||||
"type": "sequence-of-string",
|
||||
"description": "The options of the dropdown list, if the type is a choice."
|
||||
}
|
||||
}
|
||||
@@ -2419,6 +2419,11 @@
|
||||
"item-type": "non-empty-string"
|
||||
}
|
||||
},
|
||||
"sequence-of-string": {
|
||||
"sequence": {
|
||||
"item-type": "string"
|
||||
}
|
||||
},
|
||||
"boolean-needs-context": {
|
||||
"context": [
|
||||
"github",
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import {isCollection, isDocument, isMap, isPair, isScalar, isSeq, LineCounter, parseDocument, Scalar} from "yaml";
|
||||
import {
|
||||
isAlias,
|
||||
isCollection,
|
||||
isDocument,
|
||||
isMap,
|
||||
isPair,
|
||||
isScalar,
|
||||
isSeq,
|
||||
LineCounter,
|
||||
parseDocument,
|
||||
Scalar
|
||||
} from "yaml";
|
||||
import type {Document} from "yaml";
|
||||
import type {LinePos} from "yaml/dist/errors";
|
||||
import type {NodeBase} from "yaml/dist/nodes/Node";
|
||||
import {ObjectReader} from "../templates/object-reader";
|
||||
@@ -22,30 +34,31 @@ export type YamlError = {
|
||||
export class YamlObjectReader implements ObjectReader {
|
||||
private readonly _generator: Generator<ParseEvent>;
|
||||
private _current!: IteratorResult<ParseEvent>;
|
||||
private readonly doc: Document;
|
||||
private fileId?: number;
|
||||
private lineCounter = new LineCounter();
|
||||
|
||||
public errors: YamlError[] = [];
|
||||
|
||||
constructor(fileId: number | undefined, content: string) {
|
||||
const doc = parseDocument(content, {
|
||||
this.doc = parseDocument(content, {
|
||||
lineCounter: this.lineCounter,
|
||||
keepSourceTokens: true,
|
||||
uniqueKeys: false // Uniqueness is validated by the template reader
|
||||
});
|
||||
for (const err of doc.errors) {
|
||||
for (const err of this.doc.errors) {
|
||||
this.errors.push({message: err.message, range: rangeFromLinePos(err.linePos)});
|
||||
}
|
||||
this._generator = this.getNodes(doc);
|
||||
this._generator = this.getNodes(this.doc, new Set());
|
||||
this.fileId = fileId;
|
||||
}
|
||||
|
||||
private *getNodes(node: unknown): Generator<ParseEvent, void> {
|
||||
private *getNodes(node: unknown, aliasResolutionStack: Set<unknown>): Generator<ParseEvent, void> {
|
||||
let range = this.getRange(node as NodeBase | undefined);
|
||||
|
||||
if (isDocument(node)) {
|
||||
yield new ParseEvent(EventType.DocumentStart);
|
||||
for (const item of this.getNodes(node.contents)) {
|
||||
for (const item of this.getNodes(node.contents, new Set())) {
|
||||
yield item;
|
||||
}
|
||||
yield new ParseEvent(EventType.DocumentEnd);
|
||||
@@ -59,7 +72,7 @@ export class YamlObjectReader implements ObjectReader {
|
||||
}
|
||||
|
||||
for (const item of node.items) {
|
||||
for (const child of this.getNodes(item)) {
|
||||
for (const child of this.getNodes(item, aliasResolutionStack)) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
@@ -74,12 +87,32 @@ export class YamlObjectReader implements ObjectReader {
|
||||
yield new ParseEvent(EventType.Literal, YamlObjectReader.getLiteralToken(this.fileId, range, node));
|
||||
}
|
||||
|
||||
// Handle YAML aliases - resolve to the anchored value
|
||||
if (isAlias(node)) {
|
||||
const resolved = node.resolve(this.doc);
|
||||
if (resolved) {
|
||||
// Prevent infinite recursion from circular aliases
|
||||
if (aliasResolutionStack.has(resolved)) {
|
||||
// Silently ignore circular reference - the missing content will cause
|
||||
// downstream validation errors which is acceptable for this edge case
|
||||
return;
|
||||
}
|
||||
// Track this node in the alias resolution stack
|
||||
const newStack = new Set(aliasResolutionStack);
|
||||
newStack.add(resolved);
|
||||
// Yield the resolved node's contents
|
||||
yield* this.getNodes(resolved, newStack);
|
||||
}
|
||||
// If unresolved, the yaml library already reports an error
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPair(node)) {
|
||||
const scalarKey = node.key as Scalar;
|
||||
range = this.getRange(scalarKey);
|
||||
const key = scalarKey.value as string;
|
||||
yield new ParseEvent(EventType.Literal, new StringToken(this.fileId, range, key, undefined));
|
||||
for (const child of this.getNodes(node.value)) {
|
||||
for (const child of this.getNodes(node.value, aliasResolutionStack)) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user