Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b56cf5c252 | |||
| bd6ce5923b | |||
| 3de9820cd8 | |||
| a7f581bde5 | |||
| 8c0a3a947b | |||
| eb71b18f2b | |||
| 92c5235a00 | |||
| 9f770badd3 | |||
| 9dd856db3d | |||
| 4a881d9ea1 | |||
| 6a0408d237 | |||
| 0c2f39f1d0 | |||
| fb5c6e4f27 | |||
| f29f508cec | |||
| d69c1fa0f3 | |||
| 191a7b6a00 | |||
| 0410ab8302 | |||
| 7ac83f43a6 | |||
| ef457b29fa | |||
| fea8440c1d | |||
| 3c0a5f79fc | |||
| 448180bd7f | |||
| d2f52a9043 |
@@ -1 +1,4 @@
|
||||
* @actions/actions-vscode-reviewers
|
||||
|
||||
# Owners maintaining https://github.com/actions/runner-images
|
||||
/languageservice/src/value-providers/default.ts @actions/runner-images-writers @actions/actions-vscode-reviewers
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
node-version: [20.x, 22.x, 24.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -37,10 +37,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 22.x
|
||||
- name: Use Node.js 24.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "16"
|
||||
node-version: 24.x
|
||||
|
||||
- name: Bump version and push
|
||||
run: |
|
||||
|
||||
@@ -50,7 +50,9 @@ jobs:
|
||||
return true;
|
||||
|
||||
release:
|
||||
environment: publish
|
||||
environment:
|
||||
name: publish
|
||||
deployment: false
|
||||
|
||||
needs: check-version-change
|
||||
if: ${{ needs.check-version-change.outputs.changed == 'true' }}
|
||||
@@ -59,7 +61,7 @@ jobs:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
PKG_VERSION: "" # will be set in the workflow
|
||||
@@ -69,9 +71,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 24.x
|
||||
cache: "npm"
|
||||
scope: '@actions'
|
||||
|
||||
- name: Parse version from lerna.json
|
||||
run: |
|
||||
@@ -97,13 +98,6 @@ jobs:
|
||||
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
|
||||
await core.summary.write();
|
||||
|
||||
- name: setup authentication
|
||||
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish packages
|
||||
run: |
|
||||
lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
||||
+2
-1
@@ -3,4 +3,5 @@ dist
|
||||
*.md
|
||||
*.js
|
||||
*.json
|
||||
*.d.ts
|
||||
*.d.ts
|
||||
/.nx/workspace-data
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.42",
|
||||
"version": "0.3.47",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -44,7 +44,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 20"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -54,7 +54,9 @@ describe("FeatureFlags", () => {
|
||||
expect(flags.getEnabledFeatures()).toEqual([
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"allowCaseFunction"
|
||||
"allowCaseFunction",
|
||||
"allowCronTimezone",
|
||||
"allowCopilotRequestsPermission"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,18 @@ export interface ExperimentalFeatures {
|
||||
* @default false
|
||||
*/
|
||||
allowCaseFunction?: boolean;
|
||||
|
||||
/**
|
||||
* Enable the timezone input in cron schedule mappings.
|
||||
* @default false
|
||||
*/
|
||||
allowCronTimezone?: boolean;
|
||||
|
||||
/**
|
||||
* Enable the copilot-requests permission in workflow permissions.
|
||||
* @default false
|
||||
*/
|
||||
allowCopilotRequestsPermission?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +60,9 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
|
||||
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"allowCaseFunction"
|
||||
"allowCaseFunction",
|
||||
"allowCronTimezone",
|
||||
"allowCopilotRequestsPermission"
|
||||
];
|
||||
|
||||
export class FeatureFlags {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.42",
|
||||
"version": "0.3.47",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -48,8 +48,8 @@
|
||||
"actions-languageserver": "./bin/actions-languageserver"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.42",
|
||||
"@actions/workflow-parser": "^0.3.42",
|
||||
"@actions/languageservice": "^0.3.47",
|
||||
"@actions/workflow-parser": "^0.3.47",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -57,7 +57,7 @@
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 20"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
@@ -73,9 +73,10 @@
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"fetch-mock": "^9.11.0",
|
||||
"jest": "^29.0.3",
|
||||
"node-fetch": "^2.6.7",
|
||||
"prettier": "^2.8.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.8.4"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.42",
|
||||
"version": "0.3.47",
|
||||
"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.42",
|
||||
"@actions/workflow-parser": "^0.3.42",
|
||||
"@actions/expressions": "^0.3.47",
|
||||
"@actions/workflow-parser": "^0.3.47",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
"yaml": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 20"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
@@ -74,6 +74,6 @@
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.8.4"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ describe("completion", () => {
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
||||
expect(result.length).toEqual(14);
|
||||
// 28 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
||||
expect(result.length).toEqual(30);
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("macos-latest");
|
||||
expect(labels).toContain("(switch to list)");
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(11);
|
||||
expect(result.length).toEqual(27);
|
||||
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("macos-latest");
|
||||
@@ -925,3 +925,121 @@ jobs:
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule timezone completion", () => {
|
||||
it("includes timezone when allowCronTimezone is enabled", async () => {
|
||||
const input = `on:
|
||||
schedule:
|
||||
- |`;
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
featureFlags: new FeatureFlags({allowCronTimezone: true})
|
||||
});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("cron");
|
||||
expect(labels).toContain("timezone");
|
||||
});
|
||||
|
||||
it("excludes timezone when allowCronTimezone is disabled", async () => {
|
||||
const input = `on:
|
||||
schedule:
|
||||
- |`;
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
featureFlags: new FeatureFlags({allowCronTimezone: false})
|
||||
});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("cron");
|
||||
expect(labels).not.toContain("timezone");
|
||||
});
|
||||
|
||||
it("excludes timezone when no feature flags are provided", async () => {
|
||||
const input = `on:
|
||||
schedule:
|
||||
- |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("cron");
|
||||
expect(labels).not.toContain("timezone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions copilot-requests completion", () => {
|
||||
it("includes copilot-requests when allowCopilotRequestsPermission is enabled", async () => {
|
||||
const input = `on: push
|
||||
permissions:
|
||||
|`;
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: true})
|
||||
});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("actions");
|
||||
expect(labels).toContain("copilot-requests");
|
||||
});
|
||||
|
||||
it("excludes copilot-requests when allowCopilotRequestsPermission is disabled", async () => {
|
||||
const input = `on: push
|
||||
permissions:
|
||||
|`;
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: false})
|
||||
});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("actions");
|
||||
expect(labels).not.toContain("copilot-requests");
|
||||
});
|
||||
|
||||
it("excludes copilot-requests when no feature flags are provided", async () => {
|
||||
const input = `on: push
|
||||
permissions:
|
||||
|`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("actions");
|
||||
expect(labels).not.toContain("copilot-requests");
|
||||
});
|
||||
|
||||
it("includes copilot-requests in job-level permissions when allowCopilotRequestsPermission is enabled", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|`;
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: true})
|
||||
});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("actions");
|
||||
expect(labels).toContain("copilot-requests");
|
||||
});
|
||||
|
||||
it("excludes copilot-requests from job-level permissions when allowCopilotRequestsPermission is disabled", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|`;
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
featureFlags: new FeatureFlags({allowCopilotRequestsPermission: false})
|
||||
});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("actions");
|
||||
expect(labels).not.toContain("copilot-requests");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,6 +163,19 @@ export async function complete(
|
||||
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
|
||||
}
|
||||
|
||||
// Filter `timezone` from schedule completions when the feature flag is disabled
|
||||
if (!config?.featureFlags?.isEnabled("allowCronTimezone") && parent?.definition?.key === "schedule") {
|
||||
values = values.filter(v => v.label !== "timezone");
|
||||
}
|
||||
|
||||
// Filter `copilot-requests` from permissions completions when the feature flag is disabled
|
||||
if (
|
||||
!config?.featureFlags?.isEnabled("allowCopilotRequestsPermission") &&
|
||||
parent?.definition?.key === "permissions-mapping"
|
||||
) {
|
||||
values = values.filter(v => v.label !== "copilot-requests");
|
||||
}
|
||||
|
||||
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
||||
values.push(...escapeHatches);
|
||||
|
||||
@@ -120,7 +120,9 @@ jobs:
|
||||
`;
|
||||
const result = await hover(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result?.contents).toEqual("");
|
||||
expect(result?.contents).toEqual(
|
||||
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
|
||||
);
|
||||
});
|
||||
|
||||
it("on an invalid cron schedule", async () => {
|
||||
@@ -130,7 +132,9 @@ jobs:
|
||||
`;
|
||||
const result = await hover(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result?.contents).toEqual("");
|
||||
expect(result?.contents).toEqual(
|
||||
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
|
||||
);
|
||||
});
|
||||
|
||||
it("shows context inherited from parent nodes", async () => {
|
||||
@@ -195,7 +199,7 @@ jobs:
|
||||
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "step-uses", undefined));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result?.contents).toEqual(
|
||||
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image."
|
||||
"Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, a [private repository with access enabled](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository), or in a published Docker container image."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1011,4 +1011,255 @@ runs:
|
||||
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("if condition context validation", () => {
|
||||
it("warns on unknown context in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown context in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: foo == bar
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns on unknown context in pre-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown context in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: foo == bar
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns on unknown context in post-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown context in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: foo == bar
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns on unknown context in pre-if for docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown context in pre-if
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
pre-entrypoint: /setup.sh
|
||||
pre-if: foo == bar
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
|
||||
it("warns on unknown context in post-if for docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown context in post-if
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
post-entrypoint: /cleanup.sh
|
||||
post-if: foo == bar
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows valid contexts in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid context in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: github.event_name == 'push'
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows valid contexts in pre-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid context in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: runner.os == 'Linux'
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows valid contexts in post-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Valid context in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: runner.os == 'Linux'
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows hashFiles function in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: hashFiles in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: hashFiles('**/package-lock.json') != ''
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows success, failure, always, cancelled functions in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Status functions in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: success() && !cancelled()
|
||||
run: echo success
|
||||
shell: bash
|
||||
- if: failure()
|
||||
run: echo failure
|
||||
shell: bash
|
||||
- if: always()
|
||||
run: echo always
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows hashFiles function in pre-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: hashFiles in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: hashFiles('**/package-lock.json') != ''
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allows status functions in post-if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Status functions in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: always() || failure()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||
});
|
||||
|
||||
it("errors on unknown function in composite step if", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown function in if
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- if: unknownFunc()
|
||||
run: echo hi
|
||||
shell: bash
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on unknown function in pre-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown function in pre-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
pre: setup.js
|
||||
pre-if: unknownFunc()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on unknown function in post-if for node action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown function in post-if
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
post: cleanup.js
|
||||
post-if: unknownFunc()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on unknown function in pre-if for docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown function in pre-if
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
pre-entrypoint: /setup.sh
|
||||
pre-if: unknownFunc()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors on unknown function in post-if for docker action", async () => {
|
||||
const doc = createActionDocument(`
|
||||
name: My Action
|
||||
description: Unknown function in post-if
|
||||
runs:
|
||||
using: docker
|
||||
image: Dockerfile
|
||||
post-entrypoint: /cleanup.sh
|
||||
post-if: unknownFunc()
|
||||
`);
|
||||
const diagnostics = await validate(doc);
|
||||
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
import {Lexer, Parser} from "@actions/expressions";
|
||||
import {Expr} from "@actions/expressions/ast";
|
||||
import {isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||
import {isMapping, isString} from "@actions/workflow-parser";
|
||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {ActionTemplate} from "@actions/workflow-parser/actions/action-template";
|
||||
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
|
||||
@@ -75,7 +76,15 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get schema errors
|
||||
// Convert the action template (this may add validation errors for pre-if/post-if)
|
||||
let template: ActionTemplate | undefined;
|
||||
if (result.value) {
|
||||
template = getOrConvertActionTemplate(result.context, result.value, textDocument.uri, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
}
|
||||
|
||||
// Get schema and conversion errors (must be after conversion to include conversion errors)
|
||||
const schemaErrors = result.context.errors.getErrors();
|
||||
|
||||
// Run custom runs key validation, which also filters redundant schema errors in place
|
||||
@@ -103,13 +112,9 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
||||
}
|
||||
|
||||
// Validate composite action steps if we have a parsed result
|
||||
if (result.value) {
|
||||
const template = getOrConvertActionTemplate(result.context, result.value, textDocument.uri, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
if (result.value && template) {
|
||||
// Only composite actions have steps to validate
|
||||
if (template?.runs?.using === "composite") {
|
||||
if (template.runs?.using === "composite") {
|
||||
const steps = template.runs.steps ?? [];
|
||||
|
||||
// Find the steps sequence token from the raw parsed result
|
||||
@@ -125,22 +130,16 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
||||
await validateActionReference(diagnostics, stepToken, step, config);
|
||||
}
|
||||
|
||||
// Validate step tokens (uses format, if conditions)
|
||||
// Validate step uses format
|
||||
if (isMapping(stepToken)) {
|
||||
validateCompositeStepTokens(diagnostics, stepToken);
|
||||
validateStepUsesField(diagnostics, stepToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate pre-if and post-if for node and docker actions
|
||||
const runsMapping = findRunsMapping(result.value);
|
||||
if (runsMapping) {
|
||||
validateRunsIfConditions(diagnostics, runsMapping);
|
||||
}
|
||||
|
||||
// Validate format() calls in all expressions throughout the action
|
||||
validateAllExpressions(diagnostics, result.value);
|
||||
// Single traversal for all expression validation (like workflow's additionalValidations)
|
||||
validateAllTokens(diagnostics, result.value);
|
||||
}
|
||||
} catch (e) {
|
||||
error(`Unhandled error while validating action file: ${(e as Error).message}`);
|
||||
@@ -150,93 +149,124 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates tokens within a composite action step.
|
||||
* Checks `uses` format and `if` literal text detection.
|
||||
* Validates the `uses` field format in a composite action step.
|
||||
*/
|
||||
function validateCompositeStepTokens(diagnostics: Diagnostic[], stepToken: MappingToken): void {
|
||||
function validateStepUsesField(diagnostics: Diagnostic[], stepToken: MappingToken): void {
|
||||
for (let i = 0; i < stepToken.count; i++) {
|
||||
const {key, value} = stepToken.get(i);
|
||||
const keyStr = isString(key) ? key.value.toLowerCase() : "";
|
||||
|
||||
// Validate `uses` field format
|
||||
if (keyStr === "uses" && isString(value)) {
|
||||
validateStepUsesFormat(diagnostics, value);
|
||||
}
|
||||
|
||||
// Validate `if` field for literal text outside expressions
|
||||
if (keyStr === "if" && value.range) {
|
||||
if (isString(value)) {
|
||||
// Plain string if condition (no ${{ }} markers)
|
||||
validateIfCondition(diagnostics, value);
|
||||
} else if (isBasicExpression(value)) {
|
||||
// Expression token - check for format() with literal text
|
||||
// This happens when the parser converts "push == ${{ expr }}" to format('push == {0}', expr)
|
||||
validateIfConditionExpression(diagnostics, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an `if` condition (StringToken).
|
||||
* Checks for literal text outside expressions and validates format() calls.
|
||||
* Single traversal validation for all tokens in the action template.
|
||||
* This follows the same pattern as workflow validation's additionalValidations:
|
||||
* - For BasicExpressionToken: validate format() calls
|
||||
* - For StringToken on if conditions: validate literal text detection and format() calls
|
||||
* - For pre-if/post-if with explicit ${{ }}: report error (not supported by runner)
|
||||
*
|
||||
* Context validation (unknown named values) is handled by workflow-parser during conversion.
|
||||
*/
|
||||
function validateIfCondition(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
function validateAllTokens(diagnostics: Diagnostic[], root: TemplateToken): void {
|
||||
for (const [parent, token] of TemplateToken.traverse(root)) {
|
||||
const definitionKey = token.definition?.key;
|
||||
|
||||
// Validate all BasicExpressionToken instances for format() calls
|
||||
if (token instanceof BasicExpressionToken && token.range) {
|
||||
// Check for literal text in if conditions (format with literal text)
|
||||
if (definitionKey === "step-if") {
|
||||
validateIfLiteralText(diagnostics, token);
|
||||
}
|
||||
|
||||
// Validate format() calls for all expressions
|
||||
for (const expression of token.originalExpressions || [token]) {
|
||||
validateExpressionFormatCalls(diagnostics, expression);
|
||||
}
|
||||
|
||||
// Check for explicit ${{ }} in pre-if/post-if (not supported by runner)
|
||||
if (definitionKey === "runs-if" && parent instanceof MappingToken) {
|
||||
// Resolve the key name (pre-if or post-if) from parent mapping
|
||||
let keyName: string | undefined;
|
||||
for (let i = 0; i < parent.count; i++) {
|
||||
const {key, value} = parent.get(i);
|
||||
if (value === token) {
|
||||
keyName = key.toString().toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyName) {
|
||||
diagnostics.push({
|
||||
message: `Explicit expression syntax \${{ }} is not supported for '${keyName}'. Remove the \${{ }} markers and use the expression directly.`,
|
||||
range: mapRange(token.range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "explicit-expression-not-allowed"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle implicit if conditions (StringToken without ${{ }})
|
||||
// These allow expression syntax without the markers
|
||||
if (isString(token) && token.range) {
|
||||
if (definitionKey === "step-if" || definitionKey === "runs-if") {
|
||||
validateImplicitIfCondition(diagnostics, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LITERAL_TEXT_IN_CONDITION_MESSAGE =
|
||||
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?";
|
||||
const LITERAL_TEXT_IN_CONDITION_CODE = "expression-literal-text-in-condition";
|
||||
|
||||
/**
|
||||
* Validates an implicit if condition (StringToken without ${{ }}).
|
||||
* Checks for literal text detection and validates format() calls.
|
||||
*/
|
||||
function validateImplicitIfCondition(diagnostics: Diagnostic[], token: StringToken): void {
|
||||
const condition = token.value.trim();
|
||||
if (!condition) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get allowed context for step-if from the token's definition
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||
|
||||
// Ensure the condition has a status function, wrapping if needed
|
||||
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
|
||||
|
||||
// Create a BasicExpressionToken for validation
|
||||
const expressionToken = new BasicExpressionToken(
|
||||
token.file,
|
||||
token.range,
|
||||
finalCondition,
|
||||
token.definitionInfo,
|
||||
undefined,
|
||||
token.source,
|
||||
undefined,
|
||||
token.blockScalarHeader
|
||||
);
|
||||
|
||||
// Check for literal text in the expression (format with literal text)
|
||||
try {
|
||||
const l = new Lexer(expressionToken.expression);
|
||||
const l = new Lexer(finalCondition);
|
||||
const lr = l.lex();
|
||||
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||
const expr = p.parse();
|
||||
|
||||
// Check for literal text in the expression (format with literal text)
|
||||
if (hasFormatWithLiteralText(expr)) {
|
||||
diagnostics.push({
|
||||
message:
|
||||
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?",
|
||||
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
|
||||
range: mapRange(token.range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "expression-literal-text-in-condition"
|
||||
code: LITERAL_TEXT_IN_CONDITION_CODE
|
||||
});
|
||||
}
|
||||
|
||||
// Validate format() function calls
|
||||
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
|
||||
} catch {
|
||||
// Ignore parse errors here - they'll be caught by schema validation
|
||||
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an `if` condition (BasicExpressionToken).
|
||||
* Checks for literal text outside expressions.
|
||||
* Called when the parser has converted "push == ${{ expr }}" to format('push == {0}', expr).
|
||||
* Note: format() validation is handled by validateAllExpressions for BasicExpressionTokens.
|
||||
* Validates a BasicExpressionToken for literal text in if conditions.
|
||||
*/
|
||||
function validateIfConditionExpression(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
|
||||
function validateIfLiteralText(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||
|
||||
@@ -248,16 +278,33 @@ function validateIfConditionExpression(diagnostics: Diagnostic[], token: BasicEx
|
||||
|
||||
if (hasFormatWithLiteralText(expr)) {
|
||||
diagnostics.push({
|
||||
message:
|
||||
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?",
|
||||
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
|
||||
range: mapRange(token.range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "expression-literal-text-in-condition"
|
||||
code: LITERAL_TEXT_IN_CONDITION_CODE
|
||||
});
|
||||
}
|
||||
// Note: format() validation is done by validateAllExpressions() for all BasicExpressionTokens
|
||||
} catch {
|
||||
// Ignore parse errors here - they'll be caught by schema validation
|
||||
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates format() function calls in an expression token.
|
||||
*/
|
||||
function validateExpressionFormatCalls(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||
|
||||
try {
|
||||
const l = new Lexer(token.expression);
|
||||
const lr = l.lex();
|
||||
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||
const expr = p.parse();
|
||||
|
||||
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
|
||||
} catch {
|
||||
// Ignore parse errors - they'll be caught by schema validation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,77 +351,6 @@ function findStepsSequence(root: TemplateToken): SequenceToken | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the runs mapping token from the raw action template.
|
||||
*/
|
||||
function findRunsMapping(root: TemplateToken): MappingToken | undefined {
|
||||
if (root instanceof MappingToken) {
|
||||
for (let i = 0; i < root.count; i++) {
|
||||
const {key, value} = root.get(i);
|
||||
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates pre-if and post-if conditions at the runs level (for node and docker actions).
|
||||
* Checks for literal text outside expressions that would always be truthy.
|
||||
*/
|
||||
function validateRunsIfConditions(diagnostics: Diagnostic[], runsMapping: MappingToken): void {
|
||||
for (let i = 0; i < runsMapping.count; i++) {
|
||||
const {key, value} = runsMapping.get(i);
|
||||
const keyStr = key.toString().toLowerCase();
|
||||
|
||||
// Validate pre-if and post-if fields for literal text
|
||||
if ((keyStr === "pre-if" || keyStr === "post-if") && value.range) {
|
||||
if (isString(value)) {
|
||||
// Plain string condition (no ${{ }} markers)
|
||||
validateIfCondition(diagnostics, value);
|
||||
} else if (isBasicExpression(value)) {
|
||||
// The runner doesn't support explicit ${{ }} syntax for pre-if/post-if
|
||||
// Only implicit expressions are allowed
|
||||
diagnostics.push({
|
||||
message: `Explicit expression syntax \${{ }} is not supported for '${keyStr}'. Remove the \${{ }} markers and use the expression directly.`,
|
||||
range: mapRange(value.range),
|
||||
severity: DiagnosticSeverity.Error,
|
||||
code: "explicit-expression-not-allowed"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates format() function calls in all expressions throughout the action template.
|
||||
* This catches format string errors in any expression, not just if conditions.
|
||||
*/
|
||||
function validateAllExpressions(diagnostics: Diagnostic[], root: TemplateToken): void {
|
||||
for (const [, token] of TemplateToken.traverse(root)) {
|
||||
if (token instanceof BasicExpressionToken) {
|
||||
// Process original expressions if available (for combined expressions like "${{ a }} text ${{ b }}")
|
||||
// This ensures error ranges point to the correct original expression location
|
||||
for (const expression of token.originalExpressions || [token]) {
|
||||
const allowedContext = expression.definitionInfo?.allowedContext || [];
|
||||
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||
|
||||
try {
|
||||
const l = new Lexer(expression.expression);
|
||||
const lr = l.lex();
|
||||
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||
const expr = p.parse();
|
||||
|
||||
validateFormatCallsAndAddDiagnostics(diagnostics, expr, expression.range);
|
||||
} catch {
|
||||
// Ignore parse errors - they'll be caught by schema validation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the keys under `runs:` are valid for the specified `using:` type.
|
||||
* Also filters out schema errors (in place) that this validation replaces with more specific messages.
|
||||
|
||||
@@ -160,6 +160,21 @@ jobs:
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("errors on unknown context in plain string if condition", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: foo == bar
|
||||
run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshot-if", () => {
|
||||
|
||||
@@ -84,7 +84,8 @@ async function validateWorkflow(textDocument: TextDocument, config?: ValidationC
|
||||
// Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information
|
||||
const template = await getOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
featureFlags: config?.featureFlags
|
||||
});
|
||||
|
||||
// Validate expressions and value providers
|
||||
|
||||
@@ -4,19 +4,36 @@ import {reusableJobInputs} from "./reusable-job-inputs.js";
|
||||
import {reusableJobSecrets} from "./reusable-job-secrets.js";
|
||||
import {stringsToValues} from "./strings-to-values.js";
|
||||
|
||||
// Refer to: https://github.com/actions/runner-images?tab=readme-ov-file#available-images
|
||||
export const DEFAULT_RUNNER_LABELS = [
|
||||
"ubuntu-latest",
|
||||
"ubuntu-24.04",
|
||||
"ubuntu-22.04",
|
||||
"ubuntu-20.04",
|
||||
"ubuntu-slim",
|
||||
"windows-latest",
|
||||
"windows-2022",
|
||||
"windows-2019",
|
||||
"macos-latest",
|
||||
"macos-15",
|
||||
"codespaces-prebuild",
|
||||
"macos-13",
|
||||
"macos-13-large",
|
||||
"macos-13-xlarge",
|
||||
"macos-14",
|
||||
"self-hosted"
|
||||
"macos-14-large",
|
||||
"macos-14-xlarge",
|
||||
"macos-15",
|
||||
"macos-15-intel",
|
||||
"macos-15-large",
|
||||
"macos-15-xlarge",
|
||||
"macos-26",
|
||||
"macos-26-large",
|
||||
"macos-26-xlarge",
|
||||
"macos-latest",
|
||||
"macos-latest-large",
|
||||
"macos-latest-xlarge",
|
||||
"self-hosted",
|
||||
"ubuntu-22.04",
|
||||
"ubuntu-22.04-arm",
|
||||
"ubuntu-24.04",
|
||||
"ubuntu-24.04-arm",
|
||||
"ubuntu-latest",
|
||||
"ubuntu-slim",
|
||||
"windows-2022",
|
||||
"windows-2025",
|
||||
"windows-2025-vs2026",
|
||||
"windows-latest"
|
||||
];
|
||||
|
||||
const runsOnValueProvider = {
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.42"
|
||||
"version": "0.3.47"
|
||||
}
|
||||
Generated
+2422
-1761
File diff suppressed because it is too large
Load Diff
+1
-4
@@ -9,10 +9,7 @@
|
||||
"./languageserver"
|
||||
],
|
||||
"devDependencies": {
|
||||
"lerna": "^8.2.2",
|
||||
"lerna": "^9.0.0",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "$typescript"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.42",
|
||||
"version": "0.3.47",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -48,12 +48,12 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.42",
|
||||
"@actions/expressions": "^0.3.47",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
"node": ">= 20"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
@@ -69,6 +69,6 @@
|
||||
"prettier": "^2.8.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.8.4"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,4 +317,53 @@ runs:
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reports error for invalid context in pre-if", () => {
|
||||
const content = `
|
||||
name: Node Action
|
||||
description: A node action
|
||||
runs:
|
||||
using: node20
|
||||
main: dist/index.js
|
||||
pre: dist/setup.js
|
||||
pre-if: foo == bar`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
expect(result.value).toBeDefined();
|
||||
if (!result.value) return;
|
||||
|
||||
// Should have no errors before conversion
|
||||
expect(result.context.errors.count).toBe(0);
|
||||
|
||||
// Convert the template - this should add the validation error
|
||||
convertActionTemplate(result.context, result.value);
|
||||
|
||||
// Should have an error now about invalid context
|
||||
expect(result.context.errors.count).toBeGreaterThan(0);
|
||||
const errors = result.context.errors.getErrors();
|
||||
expect(errors.some(e => e.rawMessage.includes("foo"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid context in pre-if", () => {
|
||||
const content = `
|
||||
name: Node Action
|
||||
description: A node action
|
||||
runs:
|
||||
using: node20
|
||||
main: dist/index.js
|
||||
pre: dist/setup.js
|
||||
pre-if: runner.os == 'Linux'`;
|
||||
|
||||
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||
expect(result.value).toBeDefined();
|
||||
if (!result.value) return;
|
||||
|
||||
const template = convertActionTemplate(result.context, result.value);
|
||||
|
||||
// Should have no errors
|
||||
expect(result.context.errors.count).toBe(0);
|
||||
if (template.runs.using === "node20") {
|
||||
expect(template.runs.preIf).toBe("runner.os == 'Linux'");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {TemplateContext} from "../templates/template-context.js";
|
||||
import {isBoolean, isMapping, isScalar, isSequence, isString} from "../templates/tokens/type-guards.js";
|
||||
import {ErrorPolicy} from "../model/convert.js";
|
||||
import {Step} from "../model/workflow-template.js";
|
||||
import {convertToIfCondition} from "../model/converter/if-condition.js";
|
||||
import {convertToIfCondition, validateRunsIfCondition} from "../model/converter/if-condition.js";
|
||||
|
||||
/**
|
||||
* Represents a parsed and converted action.yml file
|
||||
@@ -310,7 +310,7 @@ function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns
|
||||
|
||||
case "pre-if":
|
||||
if (isString(item.value)) {
|
||||
preIf = item.value.value;
|
||||
preIf = validateRunsIfCondition(context, item.value, item.value.value);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -322,7 +322,7 @@ function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns
|
||||
|
||||
case "post-if":
|
||||
if (isString(item.value)) {
|
||||
postIf = item.value.value;
|
||||
postIf = validateRunsIfCondition(context, item.value, item.value.value);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {FeatureFlags} from "@actions/expressions/features";
|
||||
import {nullTrace} from "../test-utils/null-trace.js";
|
||||
import {parseWorkflow} from "../workflows/workflow-parser.js";
|
||||
import {convertWorkflowTemplate, ErrorPolicy} from "./convert.js";
|
||||
@@ -578,4 +579,140 @@ jobs:
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedule timezone with feature flags", () => {
|
||||
it("allows timezone when allowCronTimezone is enabled", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
timezone: America/New_York
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
featureFlags: new FeatureFlags({allowCronTimezone: true})
|
||||
});
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
expect(template.events?.schedule).toHaveLength(1);
|
||||
expect(template.events?.schedule?.[0]).toEqual({
|
||||
cron: "0 0 * * *",
|
||||
timezone: "America/New_York"
|
||||
});
|
||||
});
|
||||
|
||||
it("reports error when timezone is present but allowCronTimezone is disabled", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
timezone: America/New_York
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
featureFlags: new FeatureFlags({allowCronTimezone: false})
|
||||
});
|
||||
|
||||
// When timezone feature is disabled, error points at the timezone key
|
||||
expect(result.context.errors.getErrors()).toHaveLength(1);
|
||||
expect(result.context.errors.getErrors()[0].message).toContain("Key 'timezone' is not supported");
|
||||
// Schedule entry is dropped due to unsupported key
|
||||
expect(template.events?.schedule).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports error when timezone is present with no feature flags provided", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
timezone: America/New_York
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
// Default is timezone disabled, so error points at the timezone key
|
||||
expect(result.context.errors.getErrors()).toHaveLength(1);
|
||||
expect(result.context.errors.getErrors()[0].message).toContain("Key 'timezone' is not supported");
|
||||
});
|
||||
|
||||
it("reports error when cron is missing from schedule entry", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on:
|
||||
schedule:
|
||||
- timezone: America/New_York
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
featureFlags: new FeatureFlags({allowCronTimezone: true})
|
||||
});
|
||||
|
||||
// Both schema validation and converter report the missing cron
|
||||
expect(result.context.errors.getErrors().length).toBeGreaterThanOrEqual(1);
|
||||
const errorMessages = result.context.errors
|
||||
.getErrors()
|
||||
.map(e => e.message)
|
||||
.join(", ");
|
||||
expect(errorMessages).toMatch(/Required property is missing: cron|Missing required key 'cron'/);
|
||||
expect(template.events?.schedule).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("converts schedule without timezone when allowCronTimezone is enabled", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
featureFlags: new FeatureFlags({allowCronTimezone: true})
|
||||
});
|
||||
|
||||
expect(result.context.errors.getErrors()).toHaveLength(0);
|
||||
expect(template.events?.schedule).toHaveLength(1);
|
||||
expect(template.events?.schedule?.[0]).toEqual({
|
||||
cron: "0 0 * * *"
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {FeatureFlags} from "@actions/expressions/features";
|
||||
import {TemplateContext} from "../templates/template-context.js";
|
||||
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
|
||||
import {FileProvider} from "../workflows/file-provider.js";
|
||||
@@ -37,12 +38,18 @@ export type WorkflowTemplateConverterOptions = {
|
||||
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
|
||||
*/
|
||||
errorPolicy?: ErrorPolicy;
|
||||
|
||||
/**
|
||||
* Optional feature flags to control which experimental features are enabled.
|
||||
*/
|
||||
featureFlags?: FeatureFlags;
|
||||
};
|
||||
|
||||
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
|
||||
maxReusableWorkflowDepth: 4,
|
||||
fetchReusableWorkflowDepth: 0,
|
||||
errorPolicy: ErrorPolicy.ReturnErrorsOnly
|
||||
errorPolicy: ErrorPolicy.ReturnErrorsOnly,
|
||||
featureFlags: new FeatureFlags()
|
||||
};
|
||||
|
||||
export async function convertWorkflowTemplate(
|
||||
@@ -54,6 +61,11 @@ export async function convertWorkflowTemplate(
|
||||
const result = {} as WorkflowTemplate;
|
||||
const opts = getOptionsWithDefaults(options);
|
||||
|
||||
// Store feature flags in context state so converters can access them
|
||||
if (opts.featureFlags) {
|
||||
context.state["featureFlags"] = opts.featureFlags;
|
||||
}
|
||||
|
||||
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
||||
result.errors = context.errors.getErrors().map(x => ({
|
||||
Message: x.message
|
||||
@@ -142,6 +154,7 @@ function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Requ
|
||||
options.fetchReusableWorkflowDepth !== undefined
|
||||
? options.fetchReusableWorkflowDepth
|
||||
: defaultOptions.fetchReusableWorkflowDepth,
|
||||
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy
|
||||
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy,
|
||||
featureFlags: options.featureFlags ?? defaultOptions.featureFlags
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {FeatureFlags} from "@actions/expressions/features";
|
||||
import {TemplateContext} from "../../templates/template-context.js";
|
||||
import {MappingToken} from "../../templates/tokens/mapping-token.js";
|
||||
import {SequenceToken} from "../../templates/tokens/sequence-token.js";
|
||||
@@ -55,7 +56,8 @@ export function convertOn(context: TemplateContext, token: TemplateToken): Event
|
||||
// Schedule is the only event that can be a sequence, handle that separately
|
||||
if (eventName === "schedule") {
|
||||
const scheduleToken = item.value.assertSequence(`event ${eventName}`);
|
||||
result.schedule = convertSchedule(context, scheduleToken);
|
||||
const featureFlags = context.state["featureFlags"] as FeatureFlags | undefined;
|
||||
result.schedule = convertSchedule(context, scheduleToken, featureFlags);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -147,25 +149,47 @@ function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig & Vers
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
|
||||
function convertSchedule(
|
||||
context: TemplateContext,
|
||||
token: SequenceToken,
|
||||
featureFlags?: FeatureFlags
|
||||
): ScheduleConfig[] | undefined {
|
||||
const flags = featureFlags ?? new FeatureFlags();
|
||||
const allowTimezone = flags.isEnabled("allowCronTimezone");
|
||||
const result = [] as ScheduleConfig[];
|
||||
|
||||
for (const item of token) {
|
||||
const mappingToken = item.assertMapping(`event schedule`);
|
||||
if (mappingToken.count == 1) {
|
||||
const schedule = mappingToken.get(0);
|
||||
const scheduleKey = schedule.key.assertString(`schedule key`);
|
||||
if (scheduleKey.value == "cron") {
|
||||
const cron = schedule.value.assertString(`schedule cron`);
|
||||
// Validate the cron string
|
||||
const config: ScheduleConfig = {cron: ""};
|
||||
let valid = true;
|
||||
|
||||
for (const entry of mappingToken) {
|
||||
const key = entry.key.assertString(`schedule key`);
|
||||
|
||||
if (key.value === "cron") {
|
||||
const cron = entry.value.assertString(`schedule cron`);
|
||||
if (!isValidCron(cron.value)) {
|
||||
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
|
||||
}
|
||||
result.push({cron: cron.value});
|
||||
config.cron = cron.value;
|
||||
} else if (key.value === "timezone") {
|
||||
if (allowTimezone) {
|
||||
const timezone = entry.value.assertString(`schedule timezone`);
|
||||
config.timezone = timezone.value;
|
||||
} else {
|
||||
context.error(key, `Key 'timezone' is not supported`);
|
||||
valid = false;
|
||||
}
|
||||
} else {
|
||||
context.error(scheduleKey, `Invalid schedule key`);
|
||||
context.error(key, `Invalid schedule key`);
|
||||
valid = false;
|
||||
}
|
||||
} else {
|
||||
context.error(mappingToken, "Invalid format for 'schedule'");
|
||||
}
|
||||
|
||||
if (valid && config.cron) {
|
||||
result.push(config);
|
||||
} else if (valid && !config.cron) {
|
||||
context.error(mappingToken, "Missing required key 'cron' in schedule entry");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,3 +136,32 @@ function walkTreeToFindStatusFunctionCalls(tree: Expr | undefined): boolean {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a pre-if or post-if condition string.
|
||||
* Unlike step if conditions, pre-if and post-if are evaluated as-is by the runner
|
||||
* (they default to always() only when the field is missing entirely).
|
||||
* This function validates the expression and reports errors through the context.
|
||||
*
|
||||
* @param context The template context for error reporting
|
||||
* @param token The token containing the condition
|
||||
* @param condition The condition string to validate
|
||||
* @returns The validated condition string, or undefined on error
|
||||
*/
|
||||
export function validateRunsIfCondition(
|
||||
context: TemplateContext,
|
||||
token: TemplateToken,
|
||||
condition: string
|
||||
): string | undefined {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
|
||||
// Validate the expression directly - no wrapping needed for pre-if/post-if
|
||||
try {
|
||||
ExpressionToken.validateExpression(condition, allowedContext);
|
||||
} catch (err) {
|
||||
context.error(token, err as Error);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return condition;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,7 @@ export type SecretConfig = {
|
||||
|
||||
export type ScheduleConfig = {
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export type WorkflowFilterConfig = {
|
||||
|
||||
@@ -1602,6 +1602,10 @@
|
||||
"type": "permission-level-any",
|
||||
"description": "Repository contents, commits, branches, downloads, releases, and merges."
|
||||
},
|
||||
"copilot-requests": {
|
||||
"type": "permission-level-write-or-no-access",
|
||||
"description": "GitHub Copilot requests."
|
||||
},
|
||||
"deployments": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Deployments and deployment statuses."
|
||||
@@ -2172,7 +2176,7 @@
|
||||
}
|
||||
},
|
||||
"step-uses": {
|
||||
"description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image.",
|
||||
"description": "Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, a [private repository with access enabled](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository), or in a published Docker container image.",
|
||||
"string": {
|
||||
"require-non-empty": true
|
||||
}
|
||||
@@ -2620,14 +2624,25 @@
|
||||
"cron-mapping": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"cron": "cron-pattern"
|
||||
"cron": {
|
||||
"type": "cron-pattern",
|
||||
"required": true
|
||||
},
|
||||
"timezone": "timezone-string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cron-pattern": {
|
||||
"description": "A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes.",
|
||||
"string": {
|
||||
"require-non-empty": true
|
||||
}
|
||||
},
|
||||
"timezone-string": {
|
||||
"description": "A string that represents the time zone a scheduled workflow will run relative to in IANA format (e.g. 'America/New_York' or 'Europe/London'). If omitted, the workflow will run relative to midnight UTC.",
|
||||
"string": {
|
||||
"require-non-empty": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user