Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7520561423 | ||
|
|
cc316ab9de | ||
|
|
d5670c383a | ||
|
|
f62a0e189d | ||
|
|
9e1662f1d4 | ||
|
|
5db2e80f32 | ||
|
|
83de320ba9 | ||
|
|
74e6638098 | ||
|
|
f8b8b57248 | ||
|
|
aa1e7d8aec | ||
|
|
bd6ce5923b | ||
|
|
3de9820cd8 | ||
|
|
a7f581bde5 | ||
|
|
8c0a3a947b | ||
|
|
eb71b18f2b | ||
|
|
92c5235a00 | ||
|
|
9f770badd3 | ||
|
|
9dd856db3d | ||
|
|
4a881d9ea1 | ||
|
|
6a0408d237 | ||
|
|
0c2f39f1d0 | ||
|
|
fb5c6e4f27 | ||
|
|
f29f508cec | ||
|
|
d69c1fa0f3 | ||
|
|
191a7b6a00 | ||
|
|
0410ab8302 | ||
|
|
7ac83f43a6 | ||
|
|
ef457b29fa | ||
|
|
fea8440c1d | ||
|
|
3c0a5f79fc | ||
|
|
448180bd7f | ||
|
|
d2f52a9043 | ||
|
|
46b216a6dc | ||
|
|
0fe7798548 | ||
|
|
bdd72406c3 | ||
|
|
33291f0f8d | ||
|
|
8511ae2e6d | ||
|
|
cd1078fb2f | ||
|
|
96be7ce46c | ||
|
|
c2bf928e7b | ||
|
|
74d69b24ab | ||
|
|
22aa458809 |
@@ -1 +1,4 @@
|
|||||||
* @actions/actions-vscode-reviewers
|
* @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:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [18.x, 20.x, 22.x]
|
node-version: [20.x, 22.x, 24.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -37,10 +37,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 24.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 24.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
registry-url: 'https://npm.pkg.github.com'
|
registry-url: 'https://npm.pkg.github.com'
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "16"
|
node-version: 24.x
|
||||||
|
|
||||||
- name: Bump version and push
|
- name: Bump version and push
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
id-token: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PKG_VERSION: "" # will be set in the workflow
|
PKG_VERSION: "" # will be set in the workflow
|
||||||
@@ -69,9 +69,8 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 24.x
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
scope: '@actions'
|
|
||||||
|
|
||||||
- name: Parse version from lerna.json
|
- name: Parse version from lerna.json
|
||||||
run: |
|
run: |
|
||||||
@@ -97,13 +96,6 @@ jobs:
|
|||||||
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
|
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
|
||||||
await core.summary.write();
|
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
|
- name: Publish packages
|
||||||
run: |
|
run: |
|
||||||
lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
@@ -4,3 +4,4 @@ dist
|
|||||||
*.js
|
*.js
|
||||||
*.json
|
*.json
|
||||||
*.d.ts
|
*.d.ts
|
||||||
|
/.nx/workspace-data
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Agents
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```
|
||||||
|
npx lerna run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```
|
||||||
|
npm -w @actions/expressions test
|
||||||
|
npm -w @actions/workflow-parser test
|
||||||
|
npm -w @actions/languageservice test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Always run formatting before committing:
|
||||||
|
|
||||||
|
```
|
||||||
|
npx prettier --write <changed files>
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify with:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run format-check -ws
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature flags
|
||||||
|
|
||||||
|
Feature flags are defined in `expressions/src/features.ts` (`ExperimentalFeatures` interface + `allFeatureKeys` array). They are plumbed through `ConvertOptions`, `CompletionConfig`, `ValidationConfig`, and `initializationOptions`. When a feature graduates to stable, remove its flag and make the behavior unconditional.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/expressions",
|
"name": "@actions/expressions",
|
||||||
"version": "0.3.39",
|
"version": "0.3.51",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 20"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ describe("FeatureFlags", () => {
|
|||||||
expect(flags.getEnabledFeatures()).toEqual([
|
expect(flags.getEnabledFeatures()).toEqual([
|
||||||
"missingInputsQuickfix",
|
"missingInputsQuickfix",
|
||||||
"blockScalarChompingWarning",
|
"blockScalarChompingWarning",
|
||||||
"actionScaffoldingSnippets",
|
"allowCaseFunction",
|
||||||
"allowCaseFunction"
|
"allowCopilotRequestsPermission"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,18 +29,17 @@ export interface ExperimentalFeatures {
|
|||||||
*/
|
*/
|
||||||
blockScalarChompingWarning?: boolean;
|
blockScalarChompingWarning?: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable action scaffolding snippets in action.yml files.
|
|
||||||
* Offers Node.js, Composite, and Docker action scaffolds.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
actionScaffoldingSnippets?: boolean;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable the case() function in expressions.
|
* Enable the case() function in expressions.
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
allowCaseFunction?: boolean;
|
allowCaseFunction?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the copilot-requests permission in workflow permissions.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
allowCopilotRequestsPermission?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,8 +54,8 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
|
|||||||
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
||||||
"missingInputsQuickfix",
|
"missingInputsQuickfix",
|
||||||
"blockScalarChompingWarning",
|
"blockScalarChompingWarning",
|
||||||
"actionScaffoldingSnippets",
|
"allowCaseFunction",
|
||||||
"allowCaseFunction"
|
"allowCopilotRequestsPermission"
|
||||||
];
|
];
|
||||||
|
|
||||||
export class FeatureFlags {
|
export class FeatureFlags {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageserver",
|
"name": "@actions/languageserver",
|
||||||
"version": "0.3.39",
|
"version": "0.3.51",
|
||||||
"description": "Language server for GitHub Actions",
|
"description": "Language server for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -48,8 +48,8 @@
|
|||||||
"actions-languageserver": "./bin/actions-languageserver"
|
"actions-languageserver": "./bin/actions-languageserver"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/languageservice": "^0.3.39",
|
"@actions/languageservice": "^0.3.51",
|
||||||
"@actions/workflow-parser": "^0.3.39",
|
"@actions/workflow-parser": "^0.3.51",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
"@octokit/types": "^9.0.0",
|
"@octokit/types": "^9.0.0",
|
||||||
"vscode-languageserver": "^8.0.2",
|
"vscode-languageserver": "^8.0.2",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"yaml": "^2.1.3"
|
"yaml": "^2.1.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 20"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
@@ -73,9 +73,10 @@
|
|||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"fetch-mock": "^9.11.0",
|
"fetch-mock": "^9.11.0",
|
||||||
"jest": "^29.0.3",
|
"jest": "^29.0.3",
|
||||||
|
"node-fetch": "^2.6.7",
|
||||||
"prettier": "^2.8.3",
|
"prettier": "^2.8.3",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"typescript": "^4.8.4"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageservice",
|
"name": "@actions/languageservice",
|
||||||
"version": "0.3.39",
|
"version": "0.3.51",
|
||||||
"description": "Language service for GitHub Actions",
|
"description": "Language service for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -47,15 +47,15 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.39",
|
"@actions/expressions": "^0.3.51",
|
||||||
"@actions/workflow-parser": "^0.3.39",
|
"@actions/workflow-parser": "^0.3.51",
|
||||||
"vscode-languageserver-textdocument": "^1.0.7",
|
"vscode-languageserver-textdocument": "^1.0.7",
|
||||||
"vscode-languageserver-types": "^3.17.2",
|
"vscode-languageserver-types": "^3.17.2",
|
||||||
"vscode-uri": "^3.0.8",
|
"vscode-uri": "^3.0.8",
|
||||||
"yaml": "^2.1.1"
|
"yaml": "^2.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 20"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
@@ -74,6 +74,6 @@
|
|||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^4.8.4"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import {FeatureFlags} from "@actions/expressions";
|
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {complete, CompletionConfig} from "./complete";
|
import {complete} from "./complete";
|
||||||
import {clearCache} from "./utils/workflow-cache";
|
import {clearCache} from "./utils/workflow-cache";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearCache();
|
clearCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Config to enable action scaffolding snippets
|
|
||||||
const scaffoldingConfig: CompletionConfig = {
|
|
||||||
featureFlags: new FeatureFlags({actionScaffoldingSnippets: true})
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("complete action files", () => {
|
describe("complete action files", () => {
|
||||||
function createActionDocument(
|
function createActionDocument(
|
||||||
content: string,
|
content: string,
|
||||||
@@ -140,6 +134,49 @@ runs:
|
|||||||
expect(labels).toContain("arch");
|
expect(labels).toContain("arch");
|
||||||
expect(labels).toContain("temp");
|
expect(labels).toContain("temp");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("completes if expression value for composite run step", async () => {
|
||||||
|
const [doc, position] = createActionDocument(`name: My Action
|
||||||
|
description: Test action
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: |
|
||||||
|
run: echo "hello"
|
||||||
|
shell: bash`);
|
||||||
|
const completions = await complete(doc, position);
|
||||||
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
|
// Should show expression-related completions (status functions and contexts)
|
||||||
|
expect(labels).toContain("always");
|
||||||
|
expect(labels).toContain("success");
|
||||||
|
expect(labels).toContain("failure");
|
||||||
|
expect(labels).toContain("cancelled");
|
||||||
|
expect(labels).toContain("runner");
|
||||||
|
expect(labels).toContain("github");
|
||||||
|
expect(labels).toContain("inputs");
|
||||||
|
expect(labels).toContain("steps");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes if expression value for composite uses step", async () => {
|
||||||
|
const [doc, position] = createActionDocument(`name: My Action
|
||||||
|
description: Test action
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: |
|
||||||
|
uses: actions/checkout@v4`);
|
||||||
|
const completions = await complete(doc, position);
|
||||||
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
|
// Should show expression-related completions
|
||||||
|
expect(labels).toContain("always");
|
||||||
|
expect(labels).toContain("success");
|
||||||
|
expect(labels).toContain("failure");
|
||||||
|
expect(labels).toContain("cancelled");
|
||||||
|
expect(labels).toContain("runner");
|
||||||
|
expect(labels).toContain("github");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("top-level completions", () => {
|
describe("top-level completions", () => {
|
||||||
@@ -213,6 +250,85 @@ runs:
|
|||||||
expect(labels).not.toContain("entrypoint");
|
expect(labels).not.toContain("entrypoint");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("filters runs keys for node24 actions", async () => {
|
||||||
|
const [doc, position] = createActionDocument(`name: Test
|
||||||
|
description: Test
|
||||||
|
runs:
|
||||||
|
using: node24
|
||||||
|
|`);
|
||||||
|
const completions = await complete(doc, position);
|
||||||
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
|
// Should show Node.js action keys
|
||||||
|
expect(labels).toContain("main");
|
||||||
|
expect(labels).toContain("pre");
|
||||||
|
expect(labels).toContain("post");
|
||||||
|
expect(labels).toContain("pre-if");
|
||||||
|
expect(labels).toContain("post-if");
|
||||||
|
|
||||||
|
// Should NOT show composite or docker keys
|
||||||
|
expect(labels).not.toContain("steps");
|
||||||
|
expect(labels).not.toContain("image");
|
||||||
|
expect(labels).not.toContain("entrypoint");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes pre-if expression value for node actions", async () => {
|
||||||
|
const [doc, position] = createActionDocument(`name: Test
|
||||||
|
description: Test
|
||||||
|
runs:
|
||||||
|
using: node24
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: |`);
|
||||||
|
const completions = await complete(doc, position);
|
||||||
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
|
// Should show expression-related completions (context functions and namespaces)
|
||||||
|
expect(labels).toContain("always");
|
||||||
|
expect(labels).toContain("success");
|
||||||
|
expect(labels).toContain("failure");
|
||||||
|
expect(labels).toContain("cancelled");
|
||||||
|
expect(labels).toContain("runner");
|
||||||
|
expect(labels).toContain("github");
|
||||||
|
expect(labels).toContain("inputs");
|
||||||
|
expect(labels).toContain("hashFiles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes post-if expression value for node actions", async () => {
|
||||||
|
const [doc, position] = createActionDocument(`name: Test
|
||||||
|
description: Test
|
||||||
|
runs:
|
||||||
|
using: node24
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: |`);
|
||||||
|
const completions = await complete(doc, position);
|
||||||
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
|
// Should show expression-related completions
|
||||||
|
expect(labels).toContain("always");
|
||||||
|
expect(labels).toContain("runner");
|
||||||
|
expect(labels).toContain("hashFiles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes pre-if expression value for docker actions", async () => {
|
||||||
|
const [doc, position] = createActionDocument(`name: Test
|
||||||
|
description: Test
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: docker://alpine
|
||||||
|
pre-entrypoint: setup.sh
|
||||||
|
pre-if: |`);
|
||||||
|
const completions = await complete(doc, position);
|
||||||
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
|
// Should show expression-related completions
|
||||||
|
expect(labels).toContain("always");
|
||||||
|
expect(labels).toContain("runner");
|
||||||
|
expect(labels).toContain("github");
|
||||||
|
expect(labels).toContain("hashFiles");
|
||||||
|
});
|
||||||
|
|
||||||
it("filters runs keys for composite actions", async () => {
|
it("filters runs keys for composite actions", async () => {
|
||||||
const [doc, position] = createActionDocument(`name: Test
|
const [doc, position] = createActionDocument(`name: Test
|
||||||
description: Test
|
description: Test
|
||||||
@@ -403,7 +519,7 @@ runs:
|
|||||||
describe("action scaffolding snippets", () => {
|
describe("action scaffolding snippets", () => {
|
||||||
it("offers full scaffolding snippets in empty file", async () => {
|
it("offers full scaffolding snippets in empty file", async () => {
|
||||||
const [doc, position] = createActionDocument(`|`);
|
const [doc, position] = createActionDocument(`|`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
const labels = completions.map(c => c.label);
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
expect(labels).toContain("Node.js Action");
|
expect(labels).toContain("Node.js Action");
|
||||||
@@ -419,7 +535,7 @@ runs:
|
|||||||
it("offers full scaffolding snippets when no name or description exists", async () => {
|
it("offers full scaffolding snippets when no name or description exists", async () => {
|
||||||
const [doc, position] = createActionDocument(`author: me
|
const [doc, position] = createActionDocument(`author: me
|
||||||
|`);
|
|`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
||||||
expect(nodeSnippet).toBeDefined();
|
expect(nodeSnippet).toBeDefined();
|
||||||
@@ -430,7 +546,7 @@ runs:
|
|||||||
it("offers runs-only snippets when name exists", async () => {
|
it("offers runs-only snippets when name exists", async () => {
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
const [doc, position] = createActionDocument(`name: My Action
|
||||||
|`);
|
|`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
||||||
expect(nodeSnippet).toBeDefined();
|
expect(nodeSnippet).toBeDefined();
|
||||||
@@ -442,7 +558,7 @@ runs:
|
|||||||
it("offers runs-only snippets when description exists", async () => {
|
it("offers runs-only snippets when description exists", async () => {
|
||||||
const [doc, position] = createActionDocument(`description: Does something
|
const [doc, position] = createActionDocument(`description: Does something
|
||||||
|`);
|
|`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
|
|
||||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
||||||
expect(compositeSnippet).toBeDefined();
|
expect(compositeSnippet).toBeDefined();
|
||||||
@@ -457,7 +573,7 @@ description: Test
|
|||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
|`);
|
|`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
const labels = completions.map(c => c.label);
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
expect(labels).not.toContain("Node.js Action");
|
expect(labels).not.toContain("Node.js Action");
|
||||||
@@ -470,7 +586,7 @@ runs:
|
|||||||
description: Test
|
description: Test
|
||||||
runs:
|
runs:
|
||||||
|`);
|
|`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
const labels = completions.map(c => c.label);
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
expect(labels).toContain("Node.js Action");
|
expect(labels).toContain("Node.js Action");
|
||||||
@@ -484,7 +600,7 @@ description: Test
|
|||||||
runs:
|
runs:
|
||||||
steps: []
|
steps: []
|
||||||
|`);
|
|`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
const labels = completions.map(c => c.label);
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
expect(labels).not.toContain("Node.js Action");
|
expect(labels).not.toContain("Node.js Action");
|
||||||
@@ -499,7 +615,7 @@ runs:
|
|||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- |`);
|
- |`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
const labels = completions.map(c => c.label);
|
const labels = completions.map(c => c.label);
|
||||||
|
|
||||||
expect(labels).not.toContain("Node.js Action");
|
expect(labels).not.toContain("Node.js Action");
|
||||||
@@ -509,7 +625,7 @@ runs:
|
|||||||
|
|
||||||
it("Node.js snippet contains expected content", async () => {
|
it("Node.js snippet contains expected content", async () => {
|
||||||
const [doc, position] = createActionDocument(`|`);
|
const [doc, position] = createActionDocument(`|`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
||||||
const text = (nodeSnippet?.textEdit as {newText: string})?.newText;
|
const text = (nodeSnippet?.textEdit as {newText: string})?.newText;
|
||||||
@@ -522,7 +638,7 @@ runs:
|
|||||||
|
|
||||||
it("Composite snippet contains expected content", async () => {
|
it("Composite snippet contains expected content", async () => {
|
||||||
const [doc, position] = createActionDocument(`|`);
|
const [doc, position] = createActionDocument(`|`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
|
|
||||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
||||||
const text = (compositeSnippet?.textEdit as {newText: string})?.newText;
|
const text = (compositeSnippet?.textEdit as {newText: string})?.newText;
|
||||||
@@ -534,7 +650,7 @@ runs:
|
|||||||
|
|
||||||
it("Docker snippet contains expected content", async () => {
|
it("Docker snippet contains expected content", async () => {
|
||||||
const [doc, position] = createActionDocument(`|`);
|
const [doc, position] = createActionDocument(`|`);
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
const completions = await complete(doc, position);
|
||||||
|
|
||||||
const dockerSnippet = completions.find(c => c.label === "Docker Action");
|
const dockerSnippet = completions.find(c => c.label === "Docker Action");
|
||||||
const text = (dockerSnippet?.textEdit as {newText: string})?.newText;
|
const text = (dockerSnippet?.textEdit as {newText: string})?.newText;
|
||||||
@@ -544,14 +660,30 @@ runs:
|
|||||||
expect(text).toContain("entrypoint:");
|
expect(text).toContain("entrypoint:");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not offer snippets when feature flag is disabled", async () => {
|
it("replaces typed text when selecting scaffolding snippet", async () => {
|
||||||
|
// User typed "compo" and then triggered completion
|
||||||
|
const [doc, position] = createActionDocument(`compo|`);
|
||||||
|
const completions = await complete(doc, position);
|
||||||
|
|
||||||
|
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
||||||
|
expect(compositeSnippet).toBeDefined();
|
||||||
|
|
||||||
|
// The textEdit should replace "compo", not insert after it
|
||||||
|
const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}};
|
||||||
|
expect(textEdit.range.start.character).toBe(0); // Start of "compo"
|
||||||
|
expect(textEdit.range.end.character).toBe(5); // End of "compo"
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty file with no typed text", async () => {
|
||||||
const [doc, position] = createActionDocument(`|`);
|
const [doc, position] = createActionDocument(`|`);
|
||||||
const completions = await complete(doc, position);
|
const completions = await complete(doc, position);
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
expect(labels).not.toContain("Node.js Action");
|
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
||||||
expect(labels).not.toContain("Composite Action");
|
const textEdit = compositeSnippet?.textEdit as {range: {start: {character: number}; end: {character: number}}};
|
||||||
expect(labels).not.toContain("Docker Action");
|
|
||||||
|
// Zero-length range is fine when there's nothing to replace
|
||||||
|
expect(textEdit.range.start.character).toBe(0);
|
||||||
|
expect(textEdit.range.end.character).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
import {Position} from "vscode-languageserver-textdocument";
|
import {Position} from "vscode-languageserver-textdocument";
|
||||||
import {CompletionItem, CompletionItemKind, InsertTextFormat, TextEdit} from "vscode-languageserver-types";
|
import {CompletionItem, CompletionItemKind, InsertTextFormat, Range, TextEdit} from "vscode-languageserver-types";
|
||||||
import {Value} from "./value-providers/config.js";
|
import {Value} from "./value-providers/config.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,9 +53,6 @@ runs:
|
|||||||
# const greeting = \\\`Hello \\\${name}\\\`;
|
# const greeting = \\\`Hello \\\${name}\\\`;
|
||||||
# console.log(greeting);
|
# console.log(greeting);
|
||||||
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
|
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
|
||||||
#
|
|
||||||
# For JavaScript actions with @actions/toolkit, see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ACTION_SNIPPET_NODEJS_RUNS = `inputs:
|
const ACTION_SNIPPET_NODEJS_RUNS = `inputs:
|
||||||
@@ -320,7 +317,8 @@ export function filterActionRunsCompletions(values: Value[], path: TemplateToken
|
|||||||
export function getActionScaffoldingSnippets(
|
export function getActionScaffoldingSnippets(
|
||||||
root: TemplateToken | undefined,
|
root: TemplateToken | undefined,
|
||||||
path: TemplateToken[],
|
path: TemplateToken[],
|
||||||
position: Position
|
position: Position,
|
||||||
|
replaceRange?: Range
|
||||||
): CompletionItem[] {
|
): CompletionItem[] {
|
||||||
// Get the runs mapping from the root, if it exists
|
// Get the runs mapping from the root, if it exists
|
||||||
let runsMapping: MappingToken | undefined;
|
let runsMapping: MappingToken | undefined;
|
||||||
@@ -351,24 +349,27 @@ export function getActionScaffoldingSnippets(
|
|||||||
return [
|
return [
|
||||||
createSnippetCompletion(
|
createSnippetCompletion(
|
||||||
"Node.js Action",
|
"Node.js Action",
|
||||||
"Scaffold a Node.js action",
|
"Scaffold a Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
|
||||||
ACTION_SNIPPET_NODEJS_USING,
|
ACTION_SNIPPET_NODEJS_USING,
|
||||||
position,
|
position,
|
||||||
"0_nodejs"
|
"0_nodejs",
|
||||||
|
replaceRange
|
||||||
),
|
),
|
||||||
createSnippetCompletion(
|
createSnippetCompletion(
|
||||||
"Composite Action",
|
"Composite Action",
|
||||||
"Scaffold a composite action",
|
"Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
|
||||||
ACTION_SNIPPET_COMPOSITE_USING,
|
ACTION_SNIPPET_COMPOSITE_USING,
|
||||||
position,
|
position,
|
||||||
"1_composite"
|
"1_composite",
|
||||||
|
replaceRange
|
||||||
),
|
),
|
||||||
createSnippetCompletion(
|
createSnippetCompletion(
|
||||||
"Docker Action",
|
"Docker Action",
|
||||||
"Scaffold a Docker action",
|
"Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
|
||||||
ACTION_SNIPPET_DOCKER_USING,
|
ACTION_SNIPPET_DOCKER_USING,
|
||||||
position,
|
position,
|
||||||
"2_docker"
|
"2_docker",
|
||||||
|
replaceRange
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -396,24 +397,27 @@ export function getActionScaffoldingSnippets(
|
|||||||
return [
|
return [
|
||||||
createSnippetCompletion(
|
createSnippetCompletion(
|
||||||
"Node.js Action",
|
"Node.js Action",
|
||||||
"Scaffold a Node.js action",
|
"Scaffold a Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
|
||||||
ACTION_SNIPPET_NODEJS_RUNS,
|
ACTION_SNIPPET_NODEJS_RUNS,
|
||||||
position,
|
position,
|
||||||
"1_nodejs"
|
"1_nodejs",
|
||||||
|
replaceRange
|
||||||
),
|
),
|
||||||
createSnippetCompletion(
|
createSnippetCompletion(
|
||||||
"Composite Action",
|
"Composite Action",
|
||||||
"Scaffold a composite action",
|
"Scaffold a composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
|
||||||
ACTION_SNIPPET_COMPOSITE_RUNS,
|
ACTION_SNIPPET_COMPOSITE_RUNS,
|
||||||
position,
|
position,
|
||||||
"2_composite"
|
"2_composite",
|
||||||
|
replaceRange
|
||||||
),
|
),
|
||||||
createSnippetCompletion(
|
createSnippetCompletion(
|
||||||
"Docker Action",
|
"Docker Action",
|
||||||
"Scaffold a Docker action",
|
"Scaffold a Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
|
||||||
ACTION_SNIPPET_DOCKER_RUNS,
|
ACTION_SNIPPET_DOCKER_RUNS,
|
||||||
position,
|
position,
|
||||||
"3_docker"
|
"3_docker",
|
||||||
|
replaceRange
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -422,24 +426,27 @@ export function getActionScaffoldingSnippets(
|
|||||||
return [
|
return [
|
||||||
createSnippetCompletion(
|
createSnippetCompletion(
|
||||||
"Node.js Action",
|
"Node.js Action",
|
||||||
"Scaffold a complete Node.js action",
|
"Scaffold a complete Node.js action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action)",
|
||||||
ACTION_SNIPPET_NODEJS_FULL,
|
ACTION_SNIPPET_NODEJS_FULL,
|
||||||
position,
|
position,
|
||||||
"1_nodejs"
|
"1_nodejs",
|
||||||
|
replaceRange
|
||||||
),
|
),
|
||||||
createSnippetCompletion(
|
createSnippetCompletion(
|
||||||
"Composite Action",
|
"Composite Action",
|
||||||
"Scaffold a complete composite action",
|
"Scaffold a complete composite action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action)",
|
||||||
ACTION_SNIPPET_COMPOSITE_FULL,
|
ACTION_SNIPPET_COMPOSITE_FULL,
|
||||||
position,
|
position,
|
||||||
"2_composite"
|
"2_composite",
|
||||||
|
replaceRange
|
||||||
),
|
),
|
||||||
createSnippetCompletion(
|
createSnippetCompletion(
|
||||||
"Docker Action",
|
"Docker Action",
|
||||||
"Scaffold a complete Docker action",
|
"Scaffold a complete Docker action\n\n[Documentation](https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action)",
|
||||||
ACTION_SNIPPET_DOCKER_FULL,
|
ACTION_SNIPPET_DOCKER_FULL,
|
||||||
position,
|
position,
|
||||||
"3_docker"
|
"3_docker",
|
||||||
|
replaceRange
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -452,10 +459,15 @@ function createSnippetCompletion(
|
|||||||
description: string,
|
description: string,
|
||||||
snippetText: string,
|
snippetText: string,
|
||||||
position: Position,
|
position: Position,
|
||||||
sortText: string
|
sortText: string,
|
||||||
|
replaceRange?: Range
|
||||||
): CompletionItem {
|
): CompletionItem {
|
||||||
|
// Use replace if we have a range, otherwise insert at position
|
||||||
|
const textEdit = replaceRange ? TextEdit.replace(replaceRange, snippetText) : TextEdit.insert(position, snippetText);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
|
labelDetails: {description: "snippet"},
|
||||||
kind: CompletionItemKind.Snippet,
|
kind: CompletionItemKind.Snippet,
|
||||||
documentation: {
|
documentation: {
|
||||||
kind: "markdown",
|
kind: "markdown",
|
||||||
@@ -463,6 +475,6 @@ function createSnippetCompletion(
|
|||||||
},
|
},
|
||||||
insertTextFormat: InsertTextFormat.Snippet,
|
insertTextFormat: InsertTextFormat.Snippet,
|
||||||
sortText,
|
sortText,
|
||||||
textEdit: TextEdit.insert(position, snippetText)
|
textEdit
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import {data, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
import {data, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
||||||
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
|
import {CompletionItem, CompletionItemKind, MarkupContent} from "vscode-languageserver-types";
|
||||||
import {complete, getExpressionInput} from "./complete.js";
|
import {complete, getExpressionInput} from "./complete.js";
|
||||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||||
import {registerLogger} from "./log.js";
|
import {registerLogger} from "./log.js";
|
||||||
@@ -419,6 +419,36 @@ jobs:
|
|||||||
|
|
||||||
expect(result.map(x => x.label)).toEqual(["event"]);
|
expect(result.map(x => x.label)).toEqual(["event"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes both contexts and extension functions", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo
|
||||||
|
if: |`;
|
||||||
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
|
const labels = result.map(x => x.label);
|
||||||
|
|
||||||
|
// Context namespaces should be present
|
||||||
|
expect(labels).toContain("github");
|
||||||
|
expect(labels).toContain("runner");
|
||||||
|
expect(labels).toContain("env");
|
||||||
|
expect(labels).toContain("steps");
|
||||||
|
|
||||||
|
// Extension functions should be present (from schema context array)
|
||||||
|
expect(labels).toContain("hashFiles");
|
||||||
|
expect(labels).toContain("always");
|
||||||
|
expect(labels).toContain("success");
|
||||||
|
expect(labels).toContain("failure");
|
||||||
|
expect(labels).toContain("cancelled");
|
||||||
|
|
||||||
|
// Built-in functions should be present
|
||||||
|
expect(labels).toContain("toJson");
|
||||||
|
expect(labels).toContain("fromJson");
|
||||||
|
expect(labels).toContain("contains");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1278,6 +1308,7 @@ jobs:
|
|||||||
expect(hashFiles).toBeDefined();
|
expect(hashFiles).toBeDefined();
|
||||||
expect(hashFiles!.kind).toBe(CompletionItemKind.Function);
|
expect(hashFiles!.kind).toBe(CompletionItemKind.Function);
|
||||||
expect(hashFiles!.insertText).toBe("hashFiles()");
|
expect(hashFiles!.insertText).toBe("hashFiles()");
|
||||||
|
expect((hashFiles!.documentation as MarkupContent)?.value).toContain("Returns a single hash for the set of files");
|
||||||
|
|
||||||
// Not a function
|
// Not a function
|
||||||
const github = result.find(x => x.label === "github");
|
const github = result.find(x => x.label === "github");
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ describe("completion", () => {
|
|||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
// 28 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
||||||
expect(result.length).toEqual(14);
|
expect(result.length).toEqual(30);
|
||||||
const labels = result.map(x => x.label);
|
const labels = result.map(x => x.label);
|
||||||
expect(labels).toContain("macos-latest");
|
expect(labels).toContain("macos-latest");
|
||||||
expect(labels).toContain("(switch to list)");
|
expect(labels).toContain("(switch to list)");
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result.length).toEqual(11);
|
expect(result.length).toEqual(27);
|
||||||
|
|
||||||
const labels = result.map(x => x.label);
|
const labels = result.map(x => x.label);
|
||||||
expect(labels).toContain("macos-latest");
|
expect(labels).toContain("macos-latest");
|
||||||
@@ -925,3 +925,167 @@ jobs:
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("schedule timezone completion", () => {
|
||||||
|
it("includes timezone for schedule", 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).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permissions workflows completion", () => {
|
||||||
|
it("includes workflows in top-level permissions", 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("workflows");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers only write and none for workflows", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
permissions:
|
||||||
|
workflows: |`;
|
||||||
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
|
expect(result).not.toBeUndefined();
|
||||||
|
const labels = result.map(x => x.label);
|
||||||
|
expect(labels).toContain("write");
|
||||||
|
expect(labels).not.toContain("read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes workflows in job-level permissions", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
|`;
|
||||||
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
|
expect(result).not.toBeUndefined();
|
||||||
|
const labels = result.map(x => x.label);
|
||||||
|
expect(labels).toContain("workflows");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("service container command/entrypoint completion", () => {
|
||||||
|
it("suggests entrypoint and command in service container", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
|`;
|
||||||
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
|
expect(result).not.toBeUndefined();
|
||||||
|
const labels = result.map(x => x.label);
|
||||||
|
expect(labels).toContain("entrypoint");
|
||||||
|
expect(labels).toContain("command");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not suggest entrypoint and command in job container", async () => {
|
||||||
|
const input = `on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20
|
||||||
|
|`;
|
||||||
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
|
expect(result).not.toBeUndefined();
|
||||||
|
const labels = result.map(x => x.label);
|
||||||
|
expect(labels).not.toContain("entrypoint");
|
||||||
|
expect(labels).not.toContain("command");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {complete as completeExpression, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
import {complete as completeExpression, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
||||||
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||||
|
import {FunctionInfo} from "@actions/expressions/funcs/info";
|
||||||
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
||||||
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
|
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
|
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||||
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
|
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
|
||||||
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
||||||
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
|
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
|
||||||
@@ -19,6 +21,7 @@ import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit}
|
|||||||
import {filterActionRunsCompletions, getActionScaffoldingSnippets} from "./complete-action.js";
|
import {filterActionRunsCompletions, getActionScaffoldingSnippets} from "./complete-action.js";
|
||||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||||
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
|
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
|
||||||
|
import {getFunctionDescription} from "./context-providers/descriptions.js";
|
||||||
import {ActionContext, getActionContext} from "./context/action-context.js";
|
import {ActionContext, getActionContext} from "./context/action-context.js";
|
||||||
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
|
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
|
||||||
import {validatorFunctions} from "./expression-validation/functions.js";
|
import {validatorFunctions} from "./expression-validation/functions.js";
|
||||||
@@ -113,7 +116,8 @@ export async function complete(
|
|||||||
config,
|
config,
|
||||||
{
|
{
|
||||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||||
errorPolicy: ErrorPolicy.TryConversion
|
errorPolicy: ErrorPolicy.TryConversion,
|
||||||
|
featureFlags: config?.featureFlags
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@@ -121,18 +125,24 @@ export async function complete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expression completions
|
// Expression completions
|
||||||
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
|
if (token && (isBasicExpression(token) || isPotentiallyExpression(token, isAction))) {
|
||||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||||
|
const {namedContexts, functions: extensionFunctions} = splitAllowedContext(allowedContext);
|
||||||
const context = isAction
|
const context = isAction
|
||||||
? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion)
|
? getActionExpressionContext(namedContexts, config?.contextProviderConfig, actionContext, Mode.Completion)
|
||||||
: await getWorkflowExpressionContext(
|
: await getWorkflowExpressionContext(
|
||||||
allowedContext,
|
namedContexts,
|
||||||
config?.contextProviderConfig,
|
config?.contextProviderConfig,
|
||||||
workflowContext,
|
workflowContext,
|
||||||
Mode.Completion
|
Mode.Completion
|
||||||
);
|
);
|
||||||
|
|
||||||
return getExpressionCompletionItems(token, context, newPos, config?.featureFlags);
|
// Populate function descriptions for completion display
|
||||||
|
for (const func of extensionFunctions) {
|
||||||
|
func.description = getFunctionDescription(func.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getExpressionCompletionItems(token, context, extensionFunctions, newPos, config?.featureFlags);
|
||||||
}
|
}
|
||||||
|
|
||||||
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
|
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
|
||||||
@@ -154,16 +164,18 @@ export async function complete(
|
|||||||
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
|
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
|
||||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
||||||
values.push(...escapeHatches);
|
values.push(...escapeHatches);
|
||||||
|
|
||||||
// Get action scaffolding snippets if applicable
|
|
||||||
let actionSnippets: CompletionItem[] = [];
|
|
||||||
if (isAction && config?.featureFlags?.isEnabled("actionScaffoldingSnippets")) {
|
|
||||||
actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Figure out what text to replace when the user picks a completion.
|
// Figure out what text to replace when the user picks a completion.
|
||||||
// For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`.
|
// For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`.
|
||||||
let replaceRange: Range | undefined;
|
let replaceRange: Range | undefined;
|
||||||
@@ -191,6 +203,12 @@ export async function complete(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get action scaffolding snippets if applicable
|
||||||
|
let actionSnippets: CompletionItem[] = [];
|
||||||
|
if (isAction) {
|
||||||
|
actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position, replaceRange);
|
||||||
|
}
|
||||||
|
|
||||||
// Convert values to LSP CompletionItems
|
// Convert values to LSP CompletionItems
|
||||||
const completionItems = values.map(value => {
|
const completionItems = values.map(value => {
|
||||||
const newText = value.insertText || value.label;
|
const newText = value.insertText || value.label;
|
||||||
@@ -521,6 +539,7 @@ export function getExistingValues(token: TemplateToken | null, parent: TemplateT
|
|||||||
function getExpressionCompletionItems(
|
function getExpressionCompletionItems(
|
||||||
token: TemplateToken,
|
token: TemplateToken,
|
||||||
context: DescriptionDictionary,
|
context: DescriptionDictionary,
|
||||||
|
extensionFunctions: FunctionInfo[],
|
||||||
pos: Position,
|
pos: Position,
|
||||||
featureFlags?: FeatureFlags
|
featureFlags?: FeatureFlags
|
||||||
): CompletionItem[] {
|
): CompletionItem[] {
|
||||||
@@ -541,8 +560,8 @@ function getExpressionCompletionItems(
|
|||||||
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return completeExpression(expressionInput, context, [], validatorFunctions, featureFlags).map(item =>
|
return completeExpression(expressionInput, context, extensionFunctions, validatorFunctions, featureFlags).map(
|
||||||
mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
item => mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
||||||
|
|||||||
@@ -198,9 +198,13 @@ function getDefaultActionContext(
|
|||||||
case "runner":
|
case "runner":
|
||||||
return getRunnerContext();
|
return getRunnerContext();
|
||||||
|
|
||||||
case "env":
|
case "env": {
|
||||||
// Actions can access env but we don't have runtime values
|
// Actions can access env but we don't know what env vars the calling workflow defines
|
||||||
return new DescriptionDictionary();
|
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
|
||||||
|
const envContext = new DescriptionDictionary();
|
||||||
|
envContext.complete = false;
|
||||||
|
return envContext;
|
||||||
|
}
|
||||||
|
|
||||||
case "job": {
|
case "job": {
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||||
@@ -218,9 +222,13 @@ function getDefaultActionContext(
|
|||||||
case "strategy":
|
case "strategy":
|
||||||
return getStrategyContext();
|
return getStrategyContext();
|
||||||
|
|
||||||
case "matrix":
|
case "matrix": {
|
||||||
// Actions can access matrix context at runtime
|
// Actions can access matrix context at runtime but we don't know the calling workflow's matrix
|
||||||
return new DescriptionDictionary();
|
// Mark as incomplete to avoid false positive "Context access might be invalid" warnings
|
||||||
|
const matrixContext = new DescriptionDictionary();
|
||||||
|
matrixContext.complete = false;
|
||||||
|
return matrixContext;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -105,13 +105,6 @@
|
|||||||
"job": {
|
"job": {
|
||||||
"description": "The [`job_id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_id) of the current job.\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
|
"description": "The [`job_id`](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_id) of the current job.\nNote: This context property is set by the Actions runner, and is only available within the execution `steps` of a job. Otherwise, the value of this property will be `null`."
|
||||||
},
|
},
|
||||||
"job_workflow_sha": {
|
|
||||||
"description": "For jobs using a reusable workflow, the commit SHA for the reusable workflow file.",
|
|
||||||
"versions": {
|
|
||||||
"ghes": ">=3.9",
|
|
||||||
"ghae": ">=3.9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"path": {
|
"path": {
|
||||||
"description": "Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path).\""
|
"description": "Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"[Workflow commands for GitHub Actions](https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path).\""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
|
|||||||
"graphql_url",
|
"graphql_url",
|
||||||
"head_ref",
|
"head_ref",
|
||||||
"job",
|
"job",
|
||||||
"job_workflow_sha",
|
|
||||||
"path",
|
"path",
|
||||||
"ref",
|
"ref",
|
||||||
"ref_name",
|
"ref_name",
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
const result = await hover(...getPositionFromCursor(input));
|
const result = await hover(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
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 () => {
|
it("on an invalid cron schedule", async () => {
|
||||||
@@ -130,7 +132,9 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
const result = await hover(...getPositionFromCursor(input));
|
const result = await hover(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
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 () => {
|
it("shows context inherited from parent nodes", async () => {
|
||||||
@@ -195,7 +199,7 @@ jobs:
|
|||||||
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "step-uses", undefined));
|
const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "step-uses", undefined));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result?.contents).toEqual(
|
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."
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export async function hover(document: TextDocument, position: Position, config?:
|
|||||||
// Early exit if there's nothing to provide hover for
|
// Early exit if there's nothing to provide hover for
|
||||||
const hoverToken = token || keyToken;
|
const hoverToken = token || keyToken;
|
||||||
const isExpressionHover =
|
const isExpressionHover =
|
||||||
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token));
|
token && tokenDefinitionInfo && (isBasicExpression(token) || isPotentiallyExpression(token, isAction));
|
||||||
if (!isExpressionHover && !hoverToken?.definition) {
|
if (!isExpressionHover && !hoverToken?.definition) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import {isPotentiallyExpression} from "./expression-detection.js";
|
||||||
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
|
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
||||||
|
import {Definition} from "@actions/workflow-parser/templates/schema/definition";
|
||||||
|
|
||||||
|
// Helper to create a mock TemplateToken with the properties we need to test
|
||||||
|
function createMockToken(options: {value?: string; definitionKey?: string; isString?: boolean}): TemplateToken {
|
||||||
|
const {value = "", definitionKey, isString = true} = options;
|
||||||
|
|
||||||
|
const mockDefinition = definitionKey ? ({key: definitionKey} as Definition) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: isString ? value : undefined,
|
||||||
|
definition: mockDefinition,
|
||||||
|
templateTokenType: isString ? TokenType.String : TokenType.Mapping,
|
||||||
|
// Required by isString type guard (isLiteral checks isLiteral property)
|
||||||
|
isLiteral: isString,
|
||||||
|
isScalar: isString
|
||||||
|
} as unknown as TemplateToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isPotentiallyExpression", () => {
|
||||||
|
describe("expression markers", () => {
|
||||||
|
it("returns true when token value contains ${{", () => {
|
||||||
|
const token = createMockToken({value: "${{ github.actor }}"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when token value contains embedded ${{", () => {
|
||||||
|
const token = createMockToken({value: "Hello ${{ github.actor }}!"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when token value does not contain ${{", () => {
|
||||||
|
const token = createMockToken({value: "plain text"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for non-string tokens without expression marker", () => {
|
||||||
|
const token = createMockToken({isString: false});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("workflow schema if-conditions", () => {
|
||||||
|
it("returns true for job-if definition in workflow", () => {
|
||||||
|
const token = createMockToken({value: "success()", definitionKey: "job-if"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for job-if definition in action (not valid in action schema)", () => {
|
||||||
|
const token = createMockToken({value: "success()", definitionKey: "job-if"});
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for step-if definition in workflow", () => {
|
||||||
|
const token = createMockToken({value: "failure()", definitionKey: "step-if"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for snapshot-if definition in workflow", () => {
|
||||||
|
const token = createMockToken({value: "always()", definitionKey: "snapshot-if"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for snapshot-if definition in action (not valid in action schema)", () => {
|
||||||
|
const token = createMockToken({value: "always()", definitionKey: "snapshot-if"});
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("action schema if-conditions", () => {
|
||||||
|
describe("composite action step if (run and uses)", () => {
|
||||||
|
it("returns true for step-if definition in action", () => {
|
||||||
|
const token = createMockToken({value: "success()", definitionKey: "step-if"});
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for step-if with run step condition", () => {
|
||||||
|
// Composite action run step: if condition
|
||||||
|
const token = createMockToken({value: "github.event_name == 'push'", definitionKey: "step-if"});
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for step-if with uses step condition", () => {
|
||||||
|
// Composite action uses step: if condition
|
||||||
|
const token = createMockToken({value: "runner.os == 'Linux'", definitionKey: "step-if"});
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pre-if and post-if (node/docker actions)", () => {
|
||||||
|
it("returns true for runs-if definition in action (pre-if)", () => {
|
||||||
|
const token = createMockToken({value: "runner.os == 'Linux'", definitionKey: "runs-if"});
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for runs-if definition in action (post-if)", () => {
|
||||||
|
const token = createMockToken({value: "always()", definitionKey: "runs-if"});
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for runs-if definition in workflow (not valid in workflow schema)", () => {
|
||||||
|
const token = createMockToken({value: "always()", definitionKey: "runs-if"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mixed scenarios", () => {
|
||||||
|
it("returns true when expression marker present even if definition is not if-related", () => {
|
||||||
|
const token = createMockToken({value: "${{ github.actor }}", definitionKey: "some-other-definition"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when both expression marker and if definition present", () => {
|
||||||
|
const token = createMockToken({value: "${{ success() }}", definitionKey: "step-if"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for plain text with non-if definition", () => {
|
||||||
|
const token = createMockToken({value: "plain text", definitionKey: "string"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when token has no definition and no expression marker", () => {
|
||||||
|
const token = createMockToken({value: "plain text"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("handles empty string value", () => {
|
||||||
|
const token = createMockToken({value: ""});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles expression marker as if-condition value", () => {
|
||||||
|
const token = createMockToken({value: "${{ always() }}", definitionKey: "job-if"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(true);
|
||||||
|
// For action, job-if is not valid, but ${{ is present
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles partial expression marker", () => {
|
||||||
|
const token = createMockToken({value: "${incomplete"});
|
||||||
|
expect(isPotentiallyExpression(token, false)).toBe(false);
|
||||||
|
expect(isPotentiallyExpression(token, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles ${{ at different positions", () => {
|
||||||
|
const startToken = createMockToken({value: "${{ foo }} bar"});
|
||||||
|
const middleToken = createMockToken({value: "bar ${{ foo }} baz"});
|
||||||
|
const endToken = createMockToken({value: "bar ${{ foo }}"});
|
||||||
|
|
||||||
|
expect(isPotentiallyExpression(startToken, false)).toBe(true);
|
||||||
|
expect(isPotentiallyExpression(middleToken, false)).toBe(true);
|
||||||
|
expect(isPotentiallyExpression(endToken, false)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,36 @@ import {isString} from "@actions/workflow-parser";
|
|||||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||||
|
|
||||||
export function isPotentiallyExpression(token: TemplateToken): boolean {
|
/**
|
||||||
const containsExpression = isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0;
|
* Workflow schema if-condition definition keys.
|
||||||
// If conditions are always expressions (job-if, step-if, snapshot-if)
|
* - job-if: job level if condition
|
||||||
const definitionKey = token.definition?.key;
|
* - step-if: step level if condition
|
||||||
const isIfCondition = definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if";
|
* - snapshot-if: snapshot if condition
|
||||||
return containsExpression || isIfCondition;
|
*/
|
||||||
|
const WORKFLOW_IF_DEFINITIONS = new Set(["job-if", "step-if", "snapshot-if"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action schema if-condition definition keys.
|
||||||
|
* - step-if: composite action step if condition (run-step and uses-step)
|
||||||
|
* - runs-if: pre-if and post-if at the runs level (node/docker actions)
|
||||||
|
*/
|
||||||
|
const ACTION_IF_DEFINITIONS = new Set(["step-if", "runs-if"]);
|
||||||
|
|
||||||
|
export function isPotentiallyExpression(token: TemplateToken, isAction: boolean): boolean {
|
||||||
|
// Check if token contains expression syntax
|
||||||
|
if (isString(token) && token.value != null && token.value.indexOf(OPEN_EXPRESSION) >= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is an if-condition (always treated as expressions)
|
||||||
|
if (!token.definition?.key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition keys differ between workflow and action schemas
|
||||||
|
if (isAction) {
|
||||||
|
return ACTION_IF_DEFINITIONS.has(token.definition.key);
|
||||||
|
} else {
|
||||||
|
return WORKFLOW_IF_DEFINITIONS.has(token.definition.key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Shared validation utilities for `if` condition literal text detection.
|
||||||
|
* Used by both workflow and action validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {data} from "@actions/expressions";
|
||||||
|
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a format function contains literal text in its format string.
|
||||||
|
* This indicates user confusion about how expressions work.
|
||||||
|
*
|
||||||
|
* Example: format('push == {0}', github.event_name)
|
||||||
|
* The literal text "push == " will always evaluate to truthy.
|
||||||
|
*
|
||||||
|
* @param expr The expression to check
|
||||||
|
* @returns true if the expression is a format() call with literal text
|
||||||
|
*/
|
||||||
|
export function hasFormatWithLiteralText(expr: Expr): boolean {
|
||||||
|
// If this is a logical AND expression (from ensureStatusFunction wrapping)
|
||||||
|
// check the right side for the format call
|
||||||
|
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
|
||||||
|
return hasFormatWithLiteralText(expr.args[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(expr instanceof FunctionCall)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a format function
|
||||||
|
if (expr.functionName.lexeme.toLowerCase() !== "format") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the first argument is a string literal
|
||||||
|
if (expr.args.length < 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstArg = expr.args[0];
|
||||||
|
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the format string and trim whitespace
|
||||||
|
const formatString = firstArg.literal.coerceString();
|
||||||
|
const trimmed = formatString.trim();
|
||||||
|
|
||||||
|
// Check if there's literal text (non-replacement tokens) after trimming
|
||||||
|
let inToken = false;
|
||||||
|
for (let i = 0; i < trimmed.length; i++) {
|
||||||
|
if (!inToken && trimmed[i] === "{") {
|
||||||
|
inToken = true;
|
||||||
|
} else if (inToken && trimmed[i] === "}") {
|
||||||
|
inToken = false;
|
||||||
|
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
|
||||||
|
// OK - this is a replacement token like {0}, {1}, etc.
|
||||||
|
} else {
|
||||||
|
// Found literal text
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Shared validation utilities for step `uses` field format.
|
||||||
|
* Used by both workflow and action validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
|
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||||
|
import {mapRange} from "./range.js";
|
||||||
|
|
||||||
|
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
|
||||||
|
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
|
||||||
|
const SHORT_SHA_DOCS_URL =
|
||||||
|
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a ref looks like a short SHA and adds a warning if so.
|
||||||
|
* Returns true if a warning was added.
|
||||||
|
*/
|
||||||
|
export function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
|
||||||
|
if (SHORT_SHA_PATTERN.test(ref)) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
|
||||||
|
severity: DiagnosticSeverity.Warning,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "short-sha-ref",
|
||||||
|
codeDescription: {
|
||||||
|
href: SHORT_SHA_DOCS_URL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the format of a step's `uses` field.
|
||||||
|
*
|
||||||
|
* Valid formats:
|
||||||
|
* - docker://image:tag
|
||||||
|
* - ./local/path
|
||||||
|
* - .\local\path (Windows)
|
||||||
|
* - {owner}/{repo}@{ref}
|
||||||
|
* - {owner}/{repo}/{path}@{ref}
|
||||||
|
*/
|
||||||
|
export function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
||||||
|
const uses = token.value;
|
||||||
|
|
||||||
|
// Empty uses value
|
||||||
|
if (!uses) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: "'uses' value in action cannot be blank",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Docker image reference - always valid format
|
||||||
|
if (uses.startsWith("docker://")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local action path - always valid format
|
||||||
|
if (uses.startsWith("./") || uses.startsWith(".\\")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote action: must be {owner}/{repo}[/path]@{ref}
|
||||||
|
const atSegments = uses.split("@");
|
||||||
|
|
||||||
|
// Must have exactly one @
|
||||||
|
if (atSegments.length !== 2) {
|
||||||
|
addStepUsesFormatError(diagnostics, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [repoPath, gitRef] = atSegments;
|
||||||
|
|
||||||
|
// Ref cannot be empty
|
||||||
|
if (!gitRef) {
|
||||||
|
addStepUsesFormatError(diagnostics, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split by / or \ to get path segments
|
||||||
|
const pathSegments = repoPath.split(/[\\/]/);
|
||||||
|
|
||||||
|
// Must have at least owner and repo (both non-empty)
|
||||||
|
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
|
||||||
|
addStepUsesFormatError(diagnostics, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a reusable workflow reference (should be at job level, not step)
|
||||||
|
// Path would be like: owner/repo/.github/workflows/file.yml
|
||||||
|
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
|
||||||
|
diagnostics.push({
|
||||||
|
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if ref looks like a short SHA
|
||||||
|
warnIfShortSha(diagnostics, token, gitRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
code: "invalid-uses-format"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -527,4 +527,739 @@ runs:
|
|||||||
expect(diagnostics.some(d => d.message.includes("is not valid for"))).toBe(false);
|
expect(diagnostics.some(d => d.message.includes("is not valid for"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("composite step uses format validation", () => {
|
||||||
|
it("validates valid uses format with version", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Uses another action
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates docker:// uses format", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Uses docker image
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: docker://alpine:3.14
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates local ./ uses format", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Uses local action
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: ./local-action
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on missing @ref", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Missing version
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(true);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Expected format"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on invalid format", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Invalid format
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: invalid-format
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-uses-format")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns on short SHA", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Short SHA
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@a1b2c3d
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "short-sha-ref")).toBe(true);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("shortened commit SHA"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows full SHA", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Full SHA
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "short-sha-ref")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on reusable workflow in step uses", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Wrong workflow reference
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- uses: owner/repo/.github/workflows/build.yml@main
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Reusable workflows should be referenced"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("composite step if literal text validation", () => {
|
||||||
|
it("errors when literal text mixed with embedded expression", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Literal text in if
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: push == \${{ github.event_name }}
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("literal text outside replacement tokens"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid expression in if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid if expression
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: \${{ github.event_name == 'push' }}
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows if without expression markers (auto-wrapped)", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: If without markers
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: github.event_name == 'push'
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows success() function", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Success function
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: success()
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on format with literal text in if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format with literal text
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: \${{ format('event is {0}', github.event_name) }}
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows format with only replacement tokens", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format with only tokens
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: \${{ format('{0}', github.event_name) }}
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates if in uses-step", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: If in uses step
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: push == \${{ github.event_name }}
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pre-if and post-if validation", () => {
|
||||||
|
it("errors on explicit expression with literal text in pre-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Literal text in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: push == \${{ github.event_name }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
// Explicit ${{ }} syntax is not allowed for pre-if, so we get that error
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on explicit expression with literal text in post-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Literal text in post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: event == \${{ github.event_name }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
// Explicit ${{ }} syntax is not allowed for post-if, so we get that error
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on explicit expression with literal text in pre-if for docker action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Literal text in pre-if
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: Dockerfile
|
||||||
|
pre-entrypoint: /setup.sh
|
||||||
|
pre-if: push == \${{ github.event_name }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
// Explicit ${{ }} syntax is not allowed for pre-if, so we get that error
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on explicit expression with literal text in post-if for docker action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Literal text in post-if
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: Dockerfile
|
||||||
|
post-entrypoint: /cleanup.sh
|
||||||
|
post-if: event == \${{ github.event_name }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
// Explicit ${{ }} syntax is not allowed for post-if, so we get that error
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid expression in pre-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: success()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid expression in post-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: always()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on explicit expression syntax in pre-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Explicit expression in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: \${{ runner.os == 'Windows' }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("pre-if"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on explicit expression syntax in post-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Explicit expression in post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: \${{ always() }}
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "explicit-expression-not-allowed")).toBe(true);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("post-if"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows expression with failure() in post-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: failure()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows expression with cancelled() in post-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: cancelled()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "expression-literal-text-in-condition")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("format string validation", () => {
|
||||||
|
it("errors on format() with too few arguments in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format mismatch
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: format('{0} {1}', 'only-one')
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on invalid format string in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Invalid format
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: format('{', 'arg')
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on format() with too few arguments in pre-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format mismatch in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: format('{0} {1}', 'only-one')
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on format() with too few arguments in post-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format mismatch in post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: format('{0} {1} {2}', 'a', 'b')
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid format() call in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid format
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: format('{0} {1}', 'a', 'b') == 'a b'
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(false);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid format() call in pre-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid format in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: format('{0}', runner.os) == 'Linux'
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(false);
|
||||||
|
expect(diagnostics.some(d => d.code === "invalid-format-string")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on format() with too few arguments in run expression", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format mismatch in run
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- run: echo \${{ format('{0} {1}', 'only-one') }}
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on format() with too few arguments in input default", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Format mismatch in input default
|
||||||
|
inputs:
|
||||||
|
greeting:
|
||||||
|
description: Greeting message
|
||||||
|
default: \${{ format('{0} {1}', 'hello') }}
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.code === "format-arg-count-mismatch")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("if condition context validation", () => {
|
||||||
|
it("warns on unknown context in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Unknown context in if
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: foo == bar
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns on unknown context in pre-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Unknown context in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: foo == bar
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns on unknown context in post-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Unknown context in post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: foo == bar
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns on unknown context in pre-if for docker action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Unknown context in pre-if
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: Dockerfile
|
||||||
|
pre-entrypoint: /setup.sh
|
||||||
|
pre-if: foo == bar
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns on unknown context in post-if for docker action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Unknown context in post-if
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: Dockerfile
|
||||||
|
post-entrypoint: /cleanup.sh
|
||||||
|
post-if: foo == bar
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid contexts in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid context in if
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: github.event_name == 'push'
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid contexts in pre-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid context in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: runner.os == 'Linux'
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows valid contexts in post-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Valid context in post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: runner.os == 'Linux'
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized named-value"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows hashFiles function in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: hashFiles in if
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: hashFiles('**/package-lock.json') != ''
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows success, failure, always, cancelled functions in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Status functions in if
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: success() && !cancelled()
|
||||||
|
run: echo success
|
||||||
|
shell: bash
|
||||||
|
- if: failure()
|
||||||
|
run: echo failure
|
||||||
|
shell: bash
|
||||||
|
- if: always()
|
||||||
|
run: echo always
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows hashFiles function in pre-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: hashFiles in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: hashFiles('**/package-lock.json') != ''
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows status functions in post-if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Status functions in post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: always() || failure()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on unknown function in composite step if", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Unknown function in if
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- if: unknownFunc()
|
||||||
|
run: echo hi
|
||||||
|
shell: bash
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on unknown function in pre-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Unknown function in pre-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
pre: setup.js
|
||||||
|
pre-if: unknownFunc()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on unknown function in post-if for node action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Unknown function in post-if
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: index.js
|
||||||
|
post: cleanup.js
|
||||||
|
post-if: unknownFunc()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on unknown function in pre-if for docker action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Unknown function in pre-if
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: Dockerfile
|
||||||
|
pre-entrypoint: /setup.sh
|
||||||
|
pre-if: unknownFunc()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on unknown function in post-if for docker action", async () => {
|
||||||
|
const doc = createActionDocument(`
|
||||||
|
name: My Action
|
||||||
|
description: Unknown function in post-if
|
||||||
|
runs:
|
||||||
|
using: docker
|
||||||
|
image: Dockerfile
|
||||||
|
post-entrypoint: /cleanup.sh
|
||||||
|
post-if: unknownFunc()
|
||||||
|
`);
|
||||||
|
const diagnostics = await validate(doc);
|
||||||
|
expect(diagnostics.some(d => d.message.includes("Unrecognized function"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,20 +2,31 @@
|
|||||||
* Validation for action.yml / action.yaml manifest files
|
* Validation for action.yml / action.yaml manifest files
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {isMapping} from "@actions/workflow-parser";
|
import {Lexer, Parser} from "@actions/expressions";
|
||||||
|
import {Expr} from "@actions/expressions/ast";
|
||||||
|
import {isMapping, isString} from "@actions/workflow-parser";
|
||||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
|
import {ActionTemplate} from "@actions/workflow-parser/actions/action-template";
|
||||||
|
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||||
|
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
||||||
|
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||||
|
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
|
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
||||||
import {TemplateValidationError} from "@actions/workflow-parser/templates/template-validation-error";
|
import {TemplateValidationError} from "@actions/workflow-parser/templates/template-validation-error";
|
||||||
import {File} from "@actions/workflow-parser/workflows/file";
|
import {File} from "@actions/workflow-parser/workflows/file";
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
import {Diagnostic, DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
import {error} from "./log.js";
|
import {error} from "./log.js";
|
||||||
import {mapRange} from "./utils/range.js";
|
import {mapRange} from "./utils/range.js";
|
||||||
|
import {hasFormatWithLiteralText} from "./utils/validate-if.js";
|
||||||
|
import {validateStepUsesFormat} from "./utils/validate-uses.js";
|
||||||
import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
|
import {getOrConvertActionTemplate, getOrParseAction} from "./utils/workflow-cache.js";
|
||||||
import {validateActionReference} from "./validate-action-reference.js";
|
import {validateActionReference} from "./validate-action-reference.js";
|
||||||
|
import {validateFormatCalls} from "./validate-format-string.js";
|
||||||
import {ValidationConfig} from "./validate.js";
|
import {ValidationConfig} from "./validate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,7 +76,15 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
|||||||
return [];
|
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();
|
const schemaErrors = result.context.errors.getErrors();
|
||||||
|
|
||||||
// Run custom runs key validation, which also filters redundant schema errors in place
|
// Run custom runs key validation, which also filters redundant schema errors in place
|
||||||
@@ -93,13 +112,9 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate composite action steps if we have a parsed result
|
// Validate composite action steps if we have a parsed result
|
||||||
if (result.value) {
|
if (result.value && template) {
|
||||||
const template = getOrConvertActionTemplate(result.context, result.value, textDocument.uri, {
|
|
||||||
errorPolicy: ErrorPolicy.TryConversion
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only composite actions have steps to validate
|
// Only composite actions have steps to validate
|
||||||
if (template?.runs?.using === "composite") {
|
if (template.runs?.using === "composite") {
|
||||||
const steps = template.runs.steps ?? [];
|
const steps = template.runs.steps ?? [];
|
||||||
|
|
||||||
// Find the steps sequence token from the raw parsed result
|
// Find the steps sequence token from the raw parsed result
|
||||||
@@ -114,9 +129,17 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
|||||||
if (isActionStep(step) && isMapping(stepToken)) {
|
if (isActionStep(step) && isMapping(stepToken)) {
|
||||||
await validateActionReference(diagnostics, stepToken, step, config);
|
await validateActionReference(diagnostics, stepToken, step, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate step uses format
|
||||||
|
if (isMapping(stepToken)) {
|
||||||
|
validateStepUsesField(diagnostics, stepToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single traversal for all expression validation (like workflow's additionalValidations)
|
||||||
|
validateAllTokens(diagnostics, result.value);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error(`Unhandled error while validating action file: ${(e as Error).message}`);
|
error(`Unhandled error while validating action file: ${(e as Error).message}`);
|
||||||
@@ -125,6 +148,196 @@ export async function validateAction(textDocument: TextDocument, config?: Valida
|
|||||||
return diagnostics;
|
return diagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the `uses` field format in a composite action step.
|
||||||
|
*/
|
||||||
|
function validateStepUsesField(diagnostics: Diagnostic[], stepToken: MappingToken): void {
|
||||||
|
for (let i = 0; i < stepToken.count; i++) {
|
||||||
|
const {key, value} = stepToken.get(i);
|
||||||
|
const keyStr = isString(key) ? key.value.toLowerCase() : "";
|
||||||
|
|
||||||
|
if (keyStr === "uses" && isString(value)) {
|
||||||
|
validateStepUsesFormat(diagnostics, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single traversal validation for all tokens in the action template.
|
||||||
|
* This follows the same pattern as workflow validation's additionalValidations:
|
||||||
|
* - For BasicExpressionToken: validate format() calls
|
||||||
|
* - For StringToken on if conditions: validate literal text detection and format() calls
|
||||||
|
* - For pre-if/post-if with explicit ${{ }}: report error (not supported by runner)
|
||||||
|
*
|
||||||
|
* Context validation (unknown named values) is handled by workflow-parser during conversion.
|
||||||
|
*/
|
||||||
|
function validateAllTokens(diagnostics: Diagnostic[], root: TemplateToken): void {
|
||||||
|
for (const [parent, token] of TemplateToken.traverse(root)) {
|
||||||
|
const definitionKey = token.definition?.key;
|
||||||
|
|
||||||
|
// Validate all BasicExpressionToken instances for format() calls
|
||||||
|
if (token instanceof BasicExpressionToken && token.range) {
|
||||||
|
// Check for literal text in if conditions (format with literal text)
|
||||||
|
if (definitionKey === "step-if") {
|
||||||
|
validateIfLiteralText(diagnostics, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate format() calls for all expressions
|
||||||
|
for (const expression of token.originalExpressions || [token]) {
|
||||||
|
validateExpressionFormatCalls(diagnostics, expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for explicit ${{ }} in pre-if/post-if (not supported by runner)
|
||||||
|
if (definitionKey === "runs-if" && parent instanceof MappingToken) {
|
||||||
|
// Resolve the key name (pre-if or post-if) from parent mapping
|
||||||
|
let keyName: string | undefined;
|
||||||
|
for (let i = 0; i < parent.count; i++) {
|
||||||
|
const {key, value} = parent.get(i);
|
||||||
|
if (value === token) {
|
||||||
|
keyName = key.toString().toLowerCase();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyName) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Explicit expression syntax \${{ }} is not supported for '${keyName}'. Remove the \${{ }} markers and use the expression directly.`,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
code: "explicit-expression-not-allowed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle implicit if conditions (StringToken without ${{ }})
|
||||||
|
// These allow expression syntax without the markers
|
||||||
|
if (isString(token) && token.range) {
|
||||||
|
if (definitionKey === "step-if" || definitionKey === "runs-if") {
|
||||||
|
validateImplicitIfCondition(diagnostics, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LITERAL_TEXT_IN_CONDITION_MESSAGE =
|
||||||
|
"Conditional expression contains literal text outside replacement tokens. This will cause the expression to always evaluate to truthy. Did you mean to put the entire expression inside ${{ }}?";
|
||||||
|
const LITERAL_TEXT_IN_CONDITION_CODE = "expression-literal-text-in-condition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an implicit if condition (StringToken without ${{ }}).
|
||||||
|
* Checks for literal text detection and validates format() calls.
|
||||||
|
*/
|
||||||
|
function validateImplicitIfCondition(diagnostics: Diagnostic[], token: StringToken): void {
|
||||||
|
const condition = token.value.trim();
|
||||||
|
if (!condition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||||
|
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||||
|
|
||||||
|
// Ensure the condition has a status function, wrapping if needed
|
||||||
|
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const l = new Lexer(finalCondition);
|
||||||
|
const lr = l.lex();
|
||||||
|
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||||
|
const expr = p.parse();
|
||||||
|
|
||||||
|
// Check for literal text in the expression (format with literal text)
|
||||||
|
if (hasFormatWithLiteralText(expr)) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
code: LITERAL_TEXT_IN_CONDITION_CODE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate format() function calls
|
||||||
|
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a BasicExpressionToken for literal text in if conditions.
|
||||||
|
*/
|
||||||
|
function validateIfLiteralText(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
|
||||||
|
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||||
|
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const l = new Lexer(token.expression);
|
||||||
|
const lr = l.lex();
|
||||||
|
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||||
|
const expr = p.parse();
|
||||||
|
|
||||||
|
if (hasFormatWithLiteralText(expr)) {
|
||||||
|
diagnostics.push({
|
||||||
|
message: LITERAL_TEXT_IN_CONDITION_MESSAGE,
|
||||||
|
range: mapRange(token.range),
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
code: LITERAL_TEXT_IN_CONDITION_CODE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors - they'll be caught by schema validation or workflow-parser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates format() function calls in an expression token.
|
||||||
|
*/
|
||||||
|
function validateExpressionFormatCalls(diagnostics: Diagnostic[], token: BasicExpressionToken): void {
|
||||||
|
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||||
|
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const l = new Lexer(token.expression);
|
||||||
|
const lr = l.lex();
|
||||||
|
const p = new Parser(lr.tokens, namedContexts, functions);
|
||||||
|
const expr = p.parse();
|
||||||
|
|
||||||
|
validateFormatCallsAndAddDiagnostics(diagnostics, expr, token.range);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors - they'll be caught by schema validation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to validate format() function calls and add diagnostics.
|
||||||
|
*/
|
||||||
|
function validateFormatCallsAndAddDiagnostics(
|
||||||
|
diagnostics: Diagnostic[],
|
||||||
|
expr: Expr,
|
||||||
|
range: TokenRange | undefined
|
||||||
|
): void {
|
||||||
|
const formatErrors = validateFormatCalls(expr);
|
||||||
|
for (const formatError of formatErrors) {
|
||||||
|
if (formatError.type === "invalid-syntax") {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Invalid format string: ${formatError.message}`,
|
||||||
|
range: mapRange(range),
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
code: "invalid-format-string"
|
||||||
|
});
|
||||||
|
} else if (formatError.type === "arg-count-mismatch") {
|
||||||
|
diagnostics.push({
|
||||||
|
message: `Format string references argument {${formatError.expected - 1}} but only ${
|
||||||
|
formatError.provided
|
||||||
|
} argument(s) provided`,
|
||||||
|
range: mapRange(range),
|
||||||
|
severity: DiagnosticSeverity.Error,
|
||||||
|
code: "format-arg-count-mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the steps sequence token from the raw action template.
|
* Find the steps sequence token from the raw action template.
|
||||||
* Traverses the token tree looking for the "composite-steps" definition.
|
* Traverses the token tree looking for the "composite-steps" definition.
|
||||||
|
|||||||
@@ -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", () => {
|
describe("snapshot-if", () => {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {registerLogger} from "./log.js";
|
||||||
|
import {createDocument} from "./test-utils/document.js";
|
||||||
|
import {TestLogger} from "./test-utils/logger.js";
|
||||||
|
import {clearCache} from "./utils/workflow-cache.js";
|
||||||
|
import {validate} from "./validate.js";
|
||||||
|
|
||||||
|
registerLogger(new TestLogger());
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("service container command/entrypoint", () => {
|
||||||
|
it("allows command in service container", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
command: --port 6380
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
const commandErrors = result.filter(d => d.message.includes("command"));
|
||||||
|
expect(commandErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows entrypoint in service container", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
entrypoint: /usr/local/bin/redis-server
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
|
||||||
|
expect(entrypointErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows both command and entrypoint in service container", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
entrypoint: /usr/local/bin/redis-server
|
||||||
|
command: --port 6380
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
const relevantErrors = result.filter(d => d.message.includes("command") || d.message.includes("entrypoint"));
|
||||||
|
expect(relevantErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects command in job container", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20
|
||||||
|
command: node
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
const commandErrors = result.filter(d => d.message.includes("command"));
|
||||||
|
expect(commandErrors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects entrypoint in job container", async () => {
|
||||||
|
const input = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20
|
||||||
|
entrypoint: /bin/bash
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
|
const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
|
||||||
|
expect(entrypointErrors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -368,6 +368,24 @@ jobs:
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("environment deployment", () => {
|
||||||
|
it("allows deployment boolean under environment mapping", async () => {
|
||||||
|
const workflow = `
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: prod
|
||||||
|
deployment: false
|
||||||
|
steps:
|
||||||
|
- run: echo
|
||||||
|
`;
|
||||||
|
const result = await validate(createDocument("wf.yaml", workflow));
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("workflow_dispatch", () => {
|
describe("workflow_dispatch", () => {
|
||||||
it("allows empty string in choice options", async () => {
|
it("allows empty string in choice options", async () => {
|
||||||
const result = await validate(
|
const result = await validate(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {FeatureFlags, Lexer, Parser, data} from "@actions/expressions";
|
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
|
||||||
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
|
import {Expr} from "@actions/expressions/ast";
|
||||||
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
||||||
@@ -24,6 +24,8 @@ import {error} from "./log.js";
|
|||||||
import {isActionDocument} from "./utils/document-type.js";
|
import {isActionDocument} from "./utils/document-type.js";
|
||||||
import {findToken} from "./utils/find-token.js";
|
import {findToken} from "./utils/find-token.js";
|
||||||
import {mapRange} from "./utils/range.js";
|
import {mapRange} from "./utils/range.js";
|
||||||
|
import {hasFormatWithLiteralText} from "./utils/validate-if.js";
|
||||||
|
import {validateStepUsesFormat, warnIfShortSha} from "./utils/validate-uses.js";
|
||||||
import {getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
import {getOrConvertWorkflowTemplate, getOrParseWorkflow} from "./utils/workflow-cache.js";
|
||||||
import {validateActionReference} from "./validate-action-reference.js";
|
import {validateActionReference} from "./validate-action-reference.js";
|
||||||
import {validateAction} from "./validate-action.js";
|
import {validateAction} from "./validate-action.js";
|
||||||
@@ -82,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
|
// 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, {
|
const template = await getOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
|
||||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||||
errorPolicy: ErrorPolicy.TryConversion
|
errorPolicy: ErrorPolicy.TryConversion,
|
||||||
|
featureFlags: config?.featureFlags
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate expressions and value providers
|
// Validate expressions and value providers
|
||||||
@@ -285,116 +288,6 @@ function validateCronExpression(diagnostics: Diagnostic[], token: StringToken):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches a short SHA (7-8 hex characters) that looks like it should be a full SHA
|
|
||||||
const SHORT_SHA_PATTERN = /^[0-9a-f]{7,8}$/i;
|
|
||||||
const SHORT_SHA_DOCS_URL =
|
|
||||||
"https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-third-party-actions";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a ref looks like a short SHA and adds a warning if so.
|
|
||||||
* Returns true if a warning was added.
|
|
||||||
*/
|
|
||||||
function warnIfShortSha(diagnostics: Diagnostic[], token: StringToken, ref: string): boolean {
|
|
||||||
if (SHORT_SHA_PATTERN.test(ref)) {
|
|
||||||
diagnostics.push({
|
|
||||||
message: `The provided ref '${ref}' may be a shortened commit SHA. If so, please use the full 40-character commit SHA instead, as short SHAs are not supported.`,
|
|
||||||
severity: DiagnosticSeverity.Warning,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
code: "short-sha-ref",
|
|
||||||
codeDescription: {
|
|
||||||
href: SHORT_SHA_DOCS_URL
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the format of a step's `uses` field.
|
|
||||||
*
|
|
||||||
* Valid formats:
|
|
||||||
* - docker://image:tag
|
|
||||||
* - ./local/path
|
|
||||||
* - .\local\path (Windows)
|
|
||||||
* - {owner}/{repo}@{ref}
|
|
||||||
* - {owner}/{repo}/{path}@{ref}
|
|
||||||
*/
|
|
||||||
function validateStepUsesFormat(diagnostics: Diagnostic[], token: StringToken): void {
|
|
||||||
const uses = token.value;
|
|
||||||
|
|
||||||
// Empty uses value
|
|
||||||
if (!uses) {
|
|
||||||
diagnostics.push({
|
|
||||||
message: "`uses' value in action cannot be blank",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker image reference - always valid format
|
|
||||||
if (uses.startsWith("docker://")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local action path - always valid format
|
|
||||||
if (uses.startsWith("./") || uses.startsWith(".\\")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remote action: must be {owner}/{repo}[/path]@{ref}
|
|
||||||
const atSegments = uses.split("@");
|
|
||||||
|
|
||||||
// Must have exactly one @
|
|
||||||
if (atSegments.length !== 2) {
|
|
||||||
addStepUsesFormatError(diagnostics, token);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [repoPath, gitRef] = atSegments;
|
|
||||||
|
|
||||||
// Ref cannot be empty
|
|
||||||
if (!gitRef) {
|
|
||||||
addStepUsesFormatError(diagnostics, token);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split by / or \ to get path segments
|
|
||||||
const pathSegments = repoPath.split(/[\\/]/);
|
|
||||||
|
|
||||||
// Must have at least owner and repo (both non-empty)
|
|
||||||
if (pathSegments.length < 2 || !pathSegments[0] || !pathSegments[1]) {
|
|
||||||
addStepUsesFormatError(diagnostics, token);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a reusable workflow reference (should be at job level, not step)
|
|
||||||
// Path would be like: owner/repo/.github/workflows/file.yml
|
|
||||||
if (pathSegments.length >= 4 && pathSegments[2] === ".github" && pathSegments[3] === "workflows") {
|
|
||||||
diagnostics.push({
|
|
||||||
message: "Reusable workflows should be referenced at the top-level `jobs.<job_id>.uses` key, not within steps",
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warn if ref looks like a short SHA
|
|
||||||
warnIfShortSha(diagnostics, token, gitRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addStepUsesFormatError(diagnostics: Diagnostic[], token: StringToken): void {
|
|
||||||
diagnostics.push({
|
|
||||||
message: `Expected format {owner}/{repo}[/path]@{ref}. Actual '${token.value}'`,
|
|
||||||
severity: DiagnosticSeverity.Error,
|
|
||||||
range: mapRange(token.range),
|
|
||||||
code: "invalid-uses-format"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the format of a job's `uses` field (reusable workflow reference).
|
* Validates the format of a job's `uses` field (reusable workflow reference).
|
||||||
*
|
*
|
||||||
@@ -639,64 +532,6 @@ function getProviderContext(
|
|||||||
return getWorkflowContext(documentUri, template, path);
|
return getWorkflowContext(documentUri, template, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a format function contains literal text in its format string.
|
|
||||||
* This indicates user confusion about how expressions work.
|
|
||||||
*
|
|
||||||
* Example: format('push == {0}', github.event_name)
|
|
||||||
* The literal text "push == " will always evaluate to truthy.
|
|
||||||
*
|
|
||||||
* @param expr The expression to check
|
|
||||||
* @returns true if the expression is a format() call with literal text
|
|
||||||
*/
|
|
||||||
function hasFormatWithLiteralText(expr: Expr): boolean {
|
|
||||||
// If this is a logical AND expression (from ensureStatusFunction wrapping)
|
|
||||||
// check the right side for the format call
|
|
||||||
if (expr instanceof Logical && expr.operator.lexeme === "&&" && expr.args.length === 2) {
|
|
||||||
return hasFormatWithLiteralText(expr.args[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(expr instanceof FunctionCall)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a format function
|
|
||||||
if (expr.functionName.lexeme.toLowerCase() !== "format") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the first argument is a string literal
|
|
||||||
if (expr.args.length < 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstArg = expr.args[0];
|
|
||||||
if (!(firstArg instanceof Literal) || firstArg.literal.kind !== data.Kind.String) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the format string and trim whitespace
|
|
||||||
const formatString = firstArg.literal.coerceString();
|
|
||||||
const trimmed = formatString.trim();
|
|
||||||
|
|
||||||
// Check if there's literal text (non-replacement tokens) after trimming
|
|
||||||
let inToken = false;
|
|
||||||
for (let i = 0; i < trimmed.length; i++) {
|
|
||||||
if (!inToken && trimmed[i] === "{") {
|
|
||||||
inToken = true;
|
|
||||||
} else if (inToken && trimmed[i] === "}") {
|
|
||||||
inToken = false;
|
|
||||||
} else if (inToken && trimmed[i] >= "0" && trimmed[i] <= "9") {
|
|
||||||
// OK - this is a replacement token like {0}, {1}, etc.
|
|
||||||
} else {
|
|
||||||
// Found literal text
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateExpression(
|
async function validateExpression(
|
||||||
diagnostics: Diagnostic[],
|
diagnostics: Diagnostic[],
|
||||||
token: BasicExpressionToken,
|
token: BasicExpressionToken,
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
const result = await validate(createDocument("wf.yaml", input));
|
const result = await validate(createDocument("wf.yaml", input));
|
||||||
expect(result).toContainEqual({
|
expect(result).toContainEqual({
|
||||||
message: "`uses' value in action cannot be blank",
|
message: "'uses' value in action cannot be blank",
|
||||||
severity: DiagnosticSeverity.Error,
|
severity: DiagnosticSeverity.Error,
|
||||||
range: {
|
range: {
|
||||||
start: {line: 5, character: 12},
|
start: {line: 5, character: 12},
|
||||||
|
|||||||
@@ -4,19 +4,36 @@ import {reusableJobInputs} from "./reusable-job-inputs.js";
|
|||||||
import {reusableJobSecrets} from "./reusable-job-secrets.js";
|
import {reusableJobSecrets} from "./reusable-job-secrets.js";
|
||||||
import {stringsToValues} from "./strings-to-values.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 = [
|
export const DEFAULT_RUNNER_LABELS = [
|
||||||
"ubuntu-latest",
|
"codespaces-prebuild",
|
||||||
"ubuntu-24.04",
|
"macos-13",
|
||||||
"ubuntu-22.04",
|
"macos-13-large",
|
||||||
"ubuntu-20.04",
|
"macos-13-xlarge",
|
||||||
"ubuntu-slim",
|
|
||||||
"windows-latest",
|
|
||||||
"windows-2022",
|
|
||||||
"windows-2019",
|
|
||||||
"macos-latest",
|
|
||||||
"macos-15",
|
|
||||||
"macos-14",
|
"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 = {
|
const runsOnValueProvider = {
|
||||||
|
|||||||
+1
-1
@@ -6,5 +6,5 @@
|
|||||||
"languageservice",
|
"languageservice",
|
||||||
"languageserver"
|
"languageserver"
|
||||||
],
|
],
|
||||||
"version": "0.3.39"
|
"version": "0.3.51"
|
||||||
}
|
}
|
||||||
Generated
+2422
-1761
File diff suppressed because it is too large
Load Diff
+1
-4
@@ -9,10 +9,7 @@
|
|||||||
"./languageserver"
|
"./languageserver"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"lerna": "^8.2.2",
|
"lerna": "^9.0.0",
|
||||||
"typescript": "5.8.3"
|
"typescript": "5.8.3"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"typescript": "$typescript"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/workflow-parser",
|
"name": "@actions/workflow-parser",
|
||||||
"version": "0.3.39",
|
"version": "0.3.51",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
@@ -48,12 +48,12 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.39",
|
"@actions/expressions": "^0.3.51",
|
||||||
"cronstrue": "^2.21.0",
|
"cronstrue": "^2.21.0",
|
||||||
"yaml": "^2.0.0-8"
|
"yaml": "^2.0.0-8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 20"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
@@ -69,6 +69,6 @@
|
|||||||
"prettier": "^2.8.3",
|
"prettier": "^2.8.3",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"typescript": "^4.8.4"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,24 @@
|
|||||||
],
|
],
|
||||||
"string": {}
|
"string": {}
|
||||||
},
|
},
|
||||||
|
"runs-if": {
|
||||||
|
"description": "Condition to control when this action's pre or post script runs.",
|
||||||
|
"context": [
|
||||||
|
"runner",
|
||||||
|
"github",
|
||||||
|
"job",
|
||||||
|
"strategy",
|
||||||
|
"matrix",
|
||||||
|
"env",
|
||||||
|
"inputs",
|
||||||
|
"always(0,0)",
|
||||||
|
"success(0,0)",
|
||||||
|
"failure(0,0)",
|
||||||
|
"cancelled(0,0)",
|
||||||
|
"hashFiles(1,255)"
|
||||||
|
],
|
||||||
|
"string": {}
|
||||||
|
},
|
||||||
"runs": {
|
"runs": {
|
||||||
"one-of": [
|
"one-of": [
|
||||||
"container-runs",
|
"container-runs",
|
||||||
@@ -242,7 +260,7 @@
|
|||||||
"description": "Allows you to run a script before the entrypoint action begins.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-entrypoint)"
|
"description": "Allows you to run a script before the entrypoint action begins.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-entrypoint)"
|
||||||
},
|
},
|
||||||
"pre-if": {
|
"pre-if": {
|
||||||
"type": "non-empty-string",
|
"type": "runs-if",
|
||||||
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
|
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
|
||||||
},
|
},
|
||||||
"post-entrypoint": {
|
"post-entrypoint": {
|
||||||
@@ -250,7 +268,7 @@
|
|||||||
"description": "Allows you to run a cleanup script once the runs.entrypoint action has completed.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-entrypoint)"
|
"description": "Allows you to run a cleanup script once the runs.entrypoint action has completed.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-entrypoint)"
|
||||||
},
|
},
|
||||||
"post-if": {
|
"post-if": {
|
||||||
"type": "non-empty-string",
|
"type": "runs-if",
|
||||||
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
|
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +293,7 @@
|
|||||||
"description": "Allows you to run a script at the start of a job, before the main: action begins. You can use pre: to run prerequisite setup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre)"
|
"description": "Allows you to run a script at the start of a job, before the main: action begins. You can use pre: to run prerequisite setup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre)"
|
||||||
},
|
},
|
||||||
"pre-if": {
|
"pre-if": {
|
||||||
"type": "non-empty-string",
|
"type": "runs-if",
|
||||||
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
|
"description": "Allows you to define conditions for the pre: action execution. The pre: action will only run if the conditions in pre-if are met. If not set, then pre-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspre-if)"
|
||||||
},
|
},
|
||||||
"post": {
|
"post": {
|
||||||
@@ -283,7 +301,7 @@
|
|||||||
"description": "Allows you to run a script at the end of a job, once the main: action has completed. You can use post: to run cleanup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost)"
|
"description": "Allows you to run a script at the end of a job, once the main: action has completed. You can use post: to run cleanup scripts.\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost)"
|
||||||
},
|
},
|
||||||
"post-if": {
|
"post-if": {
|
||||||
"type": "non-empty-string",
|
"type": "runs-if",
|
||||||
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
|
"description": "Allows you to define conditions for the post: action execution. The post: action will only run if the conditions in post-if are met. If not set, then post-if defaults to always().\n\n[Documentation](https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,4 +317,53 @@ runs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports error for invalid context in pre-if", () => {
|
||||||
|
const content = `
|
||||||
|
name: Node Action
|
||||||
|
description: A node action
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: dist/index.js
|
||||||
|
pre: dist/setup.js
|
||||||
|
pre-if: foo == bar`;
|
||||||
|
|
||||||
|
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||||
|
expect(result.value).toBeDefined();
|
||||||
|
if (!result.value) return;
|
||||||
|
|
||||||
|
// Should have no errors before conversion
|
||||||
|
expect(result.context.errors.count).toBe(0);
|
||||||
|
|
||||||
|
// Convert the template - this should add the validation error
|
||||||
|
convertActionTemplate(result.context, result.value);
|
||||||
|
|
||||||
|
// Should have an error now about invalid context
|
||||||
|
expect(result.context.errors.count).toBeGreaterThan(0);
|
||||||
|
const errors = result.context.errors.getErrors();
|
||||||
|
expect(errors.some(e => e.rawMessage.includes("foo"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid context in pre-if", () => {
|
||||||
|
const content = `
|
||||||
|
name: Node Action
|
||||||
|
description: A node action
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: dist/index.js
|
||||||
|
pre: dist/setup.js
|
||||||
|
pre-if: runner.os == 'Linux'`;
|
||||||
|
|
||||||
|
const result = parseAction({name: "action.yml", content}, nullTrace);
|
||||||
|
expect(result.value).toBeDefined();
|
||||||
|
if (!result.value) return;
|
||||||
|
|
||||||
|
const template = convertActionTemplate(result.context, result.value);
|
||||||
|
|
||||||
|
// Should have no errors
|
||||||
|
expect(result.context.errors.count).toBe(0);
|
||||||
|
if (template.runs.using === "node20") {
|
||||||
|
expect(template.runs.preIf).toBe("runner.os == 'Linux'");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {TemplateContext} from "../templates/template-context.js";
|
|||||||
import {isBoolean, isMapping, isScalar, isSequence, isString} from "../templates/tokens/type-guards.js";
|
import {isBoolean, isMapping, isScalar, isSequence, isString} from "../templates/tokens/type-guards.js";
|
||||||
import {ErrorPolicy} from "../model/convert.js";
|
import {ErrorPolicy} from "../model/convert.js";
|
||||||
import {Step} from "../model/workflow-template.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
|
* Represents a parsed and converted action.yml file
|
||||||
@@ -310,7 +310,7 @@ function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns
|
|||||||
|
|
||||||
case "pre-if":
|
case "pre-if":
|
||||||
if (isString(item.value)) {
|
if (isString(item.value)) {
|
||||||
preIf = item.value.value;
|
preIf = validateRunsIfCondition(context, item.value, item.value.value);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ function convertRuns(context: TemplateContext, token: TemplateToken): ActionRuns
|
|||||||
|
|
||||||
case "post-if":
|
case "post-if":
|
||||||
if (isString(item.value)) {
|
if (isString(item.value)) {
|
||||||
postIf = item.value.value;
|
postIf = validateRunsIfCondition(context, item.value, item.value.value);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -578,4 +578,86 @@ jobs:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("schedule timezone", () => {
|
||||||
|
it("allows timezone in schedule", 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
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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", 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
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {TemplateContext} from "../templates/template-context.js";
|
||||||
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
|
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
|
||||||
import {FileProvider} from "../workflows/file-provider.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}.
|
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
|
||||||
*/
|
*/
|
||||||
errorPolicy?: ErrorPolicy;
|
errorPolicy?: ErrorPolicy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature flags for experimental features.
|
||||||
|
*/
|
||||||
|
featureFlags?: FeatureFlags;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
|
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
|
||||||
maxReusableWorkflowDepth: 4,
|
maxReusableWorkflowDepth: 4,
|
||||||
fetchReusableWorkflowDepth: 0,
|
fetchReusableWorkflowDepth: 0,
|
||||||
errorPolicy: ErrorPolicy.ReturnErrorsOnly
|
errorPolicy: ErrorPolicy.ReturnErrorsOnly,
|
||||||
|
featureFlags: new FeatureFlags()
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function convertWorkflowTemplate(
|
export async function convertWorkflowTemplate(
|
||||||
@@ -54,6 +61,8 @@ export async function convertWorkflowTemplate(
|
|||||||
const result = {} as WorkflowTemplate;
|
const result = {} as WorkflowTemplate;
|
||||||
const opts = getOptionsWithDefaults(options);
|
const opts = getOptionsWithDefaults(options);
|
||||||
|
|
||||||
|
context.state.featureFlags = opts.featureFlags;
|
||||||
|
|
||||||
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
||||||
result.errors = context.errors.getErrors().map(x => ({
|
result.errors = context.errors.getErrors().map(x => ({
|
||||||
Message: x.message
|
Message: x.message
|
||||||
@@ -142,6 +151,7 @@ function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Requ
|
|||||||
options.fetchReusableWorkflowDepth !== undefined
|
options.fetchReusableWorkflowDepth !== undefined
|
||||||
? options.fetchReusableWorkflowDepth
|
? options.fetchReusableWorkflowDepth
|
||||||
: defaultOptions.fetchReusableWorkflowDepth,
|
: defaultOptions.fetchReusableWorkflowDepth,
|
||||||
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy
|
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy,
|
||||||
|
featureFlags: options.featureFlags ?? defaultOptions.featureFlags
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,13 +70,87 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function convertToServiceContainer(context: TemplateContext, container: TemplateToken): Container | undefined {
|
||||||
|
let image: StringToken | undefined;
|
||||||
|
let env: MappingToken | undefined;
|
||||||
|
let ports: SequenceToken | undefined;
|
||||||
|
let volumes: SequenceToken | undefined;
|
||||||
|
let options: StringToken | undefined;
|
||||||
|
let entrypoint: StringToken | undefined;
|
||||||
|
let command: StringToken | undefined;
|
||||||
|
|
||||||
|
// Skip validation for expressions for now to match
|
||||||
|
// behavior of the other parsers
|
||||||
|
for (const [, token] of TemplateToken.traverse(container)) {
|
||||||
|
if (token.isExpression) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isString(container)) {
|
||||||
|
image = container.assertString("container item");
|
||||||
|
return {image: image};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = container.assertMapping("container item");
|
||||||
|
if (mapping)
|
||||||
|
for (const item of mapping) {
|
||||||
|
const key = item.key.assertString("container item key");
|
||||||
|
const value = item.value;
|
||||||
|
|
||||||
|
switch (key.value) {
|
||||||
|
case "image":
|
||||||
|
image = value.assertString("container image");
|
||||||
|
break;
|
||||||
|
case "credentials":
|
||||||
|
convertToJobCredentials(context, value);
|
||||||
|
break;
|
||||||
|
case "env":
|
||||||
|
env = value.assertMapping("container env");
|
||||||
|
for (const envItem of env) {
|
||||||
|
envItem.key.assertString("container env value");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ports":
|
||||||
|
ports = value.assertSequence("container ports");
|
||||||
|
for (const port of ports) {
|
||||||
|
port.assertString("container port");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "volumes":
|
||||||
|
volumes = value.assertSequence("container volumes");
|
||||||
|
for (const volume of volumes) {
|
||||||
|
volume.assertString("container volume");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "options":
|
||||||
|
options = value.assertString("container options");
|
||||||
|
break;
|
||||||
|
case "entrypoint":
|
||||||
|
entrypoint = value.assertString("container entrypoint");
|
||||||
|
break;
|
||||||
|
case "command":
|
||||||
|
command = value.assertString("container command");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
context.error(key, `Unexpected container item key: ${key.value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
context.error(container, "Container image cannot be empty");
|
||||||
|
} else {
|
||||||
|
return {image, env, ports, volumes, options, entrypoint, command};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
||||||
const serviceList: Container[] = [];
|
const serviceList: Container[] = [];
|
||||||
|
|
||||||
const mapping = services.assertMapping("services");
|
const mapping = services.assertMapping("services");
|
||||||
for (const service of mapping) {
|
for (const service of mapping) {
|
||||||
service.key.assertString("service key");
|
service.key.assertString("service key");
|
||||||
const container = convertToJobContainer(context, service.value);
|
const container = convertToServiceContainer(context, service.value);
|
||||||
if (container) {
|
if (container) {
|
||||||
serviceList.push(container);
|
serviceList.push(container);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,23 +149,34 @@ function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig & Vers
|
|||||||
|
|
||||||
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
|
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
|
||||||
const result = [] as ScheduleConfig[];
|
const result = [] as ScheduleConfig[];
|
||||||
|
|
||||||
for (const item of token) {
|
for (const item of token) {
|
||||||
const mappingToken = item.assertMapping(`event schedule`);
|
const mappingToken = item.assertMapping(`event schedule`);
|
||||||
if (mappingToken.count == 1) {
|
const config: ScheduleConfig = {cron: ""};
|
||||||
const schedule = mappingToken.get(0);
|
let valid = true;
|
||||||
const scheduleKey = schedule.key.assertString(`schedule key`);
|
|
||||||
if (scheduleKey.value == "cron") {
|
for (const entry of mappingToken) {
|
||||||
const cron = schedule.value.assertString(`schedule cron`);
|
const key = entry.key.assertString(`schedule key`);
|
||||||
// Validate the cron string
|
|
||||||
|
if (key.value === "cron") {
|
||||||
|
const cron = entry.value.assertString(`schedule cron`);
|
||||||
if (!isValidCron(cron.value)) {
|
if (!isValidCron(cron.value)) {
|
||||||
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
|
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") {
|
||||||
|
const timezone = entry.value.assertString(`schedule timezone`);
|
||||||
|
config.timezone = timezone.value;
|
||||||
} else {
|
} 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;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export function convertToActionsEnvironmentRef(
|
|||||||
case "url":
|
case "url":
|
||||||
result.url = property.value;
|
result.url = property.value;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "deployment": {
|
||||||
|
const deploymentValue = property.value.assertBoolean("job environment deployment");
|
||||||
|
if (deploymentValue.value === false) {
|
||||||
|
result.skipDeployment = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type ConcurrencySetting = {
|
|||||||
export type ActionsEnvironmentReference = {
|
export type ActionsEnvironmentReference = {
|
||||||
name?: TemplateToken;
|
name?: TemplateToken;
|
||||||
url?: TemplateToken;
|
url?: TemplateToken;
|
||||||
|
skipDeployment?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowJob = Job | ReusableWorkflowJob;
|
export type WorkflowJob = Job | ReusableWorkflowJob;
|
||||||
@@ -74,6 +75,8 @@ export type Container = {
|
|||||||
ports?: SequenceToken;
|
ports?: SequenceToken;
|
||||||
volumes?: SequenceToken;
|
volumes?: SequenceToken;
|
||||||
options?: StringToken;
|
options?: StringToken;
|
||||||
|
entrypoint?: StringToken;
|
||||||
|
command?: StringToken;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Credential = {
|
export type Credential = {
|
||||||
@@ -196,6 +199,7 @@ export type SecretConfig = {
|
|||||||
|
|
||||||
export type ScheduleConfig = {
|
export type ScheduleConfig = {
|
||||||
cron: string;
|
cron: string;
|
||||||
|
timezone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowFilterConfig = {
|
export type WorkflowFilterConfig = {
|
||||||
|
|||||||
@@ -1602,6 +1602,10 @@
|
|||||||
"type": "permission-level-any",
|
"type": "permission-level-any",
|
||||||
"description": "Repository contents, commits, branches, downloads, releases, and merges."
|
"description": "Repository contents, commits, branches, downloads, releases, and merges."
|
||||||
},
|
},
|
||||||
|
"copilot-requests": {
|
||||||
|
"type": "permission-level-write-or-no-access",
|
||||||
|
"description": "GitHub Copilot requests."
|
||||||
|
},
|
||||||
"deployments": {
|
"deployments": {
|
||||||
"type": "permission-level-any",
|
"type": "permission-level-any",
|
||||||
"description": "Deployments and deployment statuses."
|
"description": "Deployments and deployment statuses."
|
||||||
@@ -1645,6 +1649,10 @@
|
|||||||
"statuses": {
|
"statuses": {
|
||||||
"type": "permission-level-any",
|
"type": "permission-level-any",
|
||||||
"description": "Commit statuses."
|
"description": "Commit statuses."
|
||||||
|
},
|
||||||
|
"workflows": {
|
||||||
|
"type": "permission-level-write-or-no-access",
|
||||||
|
"description": "Update GitHub Actions workflow files."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2075,6 +2083,10 @@
|
|||||||
"url": {
|
"url": {
|
||||||
"type": "string-runner-context-no-secrets",
|
"type": "string-runner-context-no-secrets",
|
||||||
"description": "The environment URL, which maps to `environment_url` in the deployments API."
|
"description": "The environment URL, which maps to `environment_url` in the deployments API."
|
||||||
|
},
|
||||||
|
"deployment": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to create a deployment record for this environment. Defaults to true."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2172,7 +2184,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step-uses": {
|
"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": {
|
"string": {
|
||||||
"require-non-empty": true
|
"require-non-empty": true
|
||||||
}
|
}
|
||||||
@@ -2349,7 +2361,7 @@
|
|||||||
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
|
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"type": "non-empty-string",
|
"type": "string",
|
||||||
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
|
"description": "Use `jobs.<job_id>.container.options` to configure additional Docker container resource options."
|
||||||
},
|
},
|
||||||
"env": "container-env",
|
"env": "container-env",
|
||||||
@@ -2391,7 +2403,7 @@
|
|||||||
],
|
],
|
||||||
"one-of": [
|
"one-of": [
|
||||||
"non-empty-string",
|
"non-empty-string",
|
||||||
"container-mapping"
|
"service-container-mapping"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"container-registry-credentials": {
|
"container-registry-credentials": {
|
||||||
@@ -2620,14 +2632,57 @@
|
|||||||
"cron-mapping": {
|
"cron-mapping": {
|
||||||
"mapping": {
|
"mapping": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"cron": "cron-pattern"
|
"cron": {
|
||||||
|
"type": "cron-pattern",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"timezone": "timezone-string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cron-pattern": {
|
"cron-pattern": {
|
||||||
|
"description": "A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes.",
|
||||||
"string": {
|
"string": {
|
||||||
"require-non-empty": true
|
"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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"service-container-mapping": {
|
||||||
|
"mapping": {
|
||||||
|
"properties": {
|
||||||
|
"image": {
|
||||||
|
"type": "non-empty-string",
|
||||||
|
"description": "The Docker image to use as the container. The value can be the Docker Hub image or a registry name."
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Additional Docker container resource options."
|
||||||
|
},
|
||||||
|
"env": "container-env",
|
||||||
|
"ports": {
|
||||||
|
"type": "sequence-of-non-empty-string",
|
||||||
|
"description": "An array of ports to expose on the container."
|
||||||
|
},
|
||||||
|
"volumes": {
|
||||||
|
"type": "sequence-of-non-empty-string",
|
||||||
|
"description": "An array of volumes for the container to use. You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host."
|
||||||
|
},
|
||||||
|
"credentials": "container-registry-credentials",
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Override the default ENTRYPOINT in the service container image."
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Override the default CMD in the service container image."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
include-source: false # Drop file/line/col from output
|
||||||
|
skip:
|
||||||
|
- C#
|
||||||
|
---
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
deployment: false
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
build2:
|
||||||
|
environment:
|
||||||
|
name: staging
|
||||||
|
deployment: true
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo hi
|
||||||
|
---
|
||||||
|
{
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"type": "job",
|
||||||
|
"id": "build",
|
||||||
|
"name": "build",
|
||||||
|
"if": {
|
||||||
|
"type": 3,
|
||||||
|
"expr": "success()"
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"type": 2,
|
||||||
|
"map": [
|
||||||
|
{
|
||||||
|
"Key": "name",
|
||||||
|
"Value": "production"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": "deployment",
|
||||||
|
"Value": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runs-on": "ubuntu-latest",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "__run",
|
||||||
|
"if": {
|
||||||
|
"type": 3,
|
||||||
|
"expr": "success()"
|
||||||
|
},
|
||||||
|
"run": "echo hi"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "job",
|
||||||
|
"id": "build2",
|
||||||
|
"name": "build2",
|
||||||
|
"if": {
|
||||||
|
"type": 3,
|
||||||
|
"expr": "success()"
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"type": 2,
|
||||||
|
"map": [
|
||||||
|
{
|
||||||
|
"Key": "name",
|
||||||
|
"Value": "staging"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": "deployment",
|
||||||
|
"Value": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runs-on": "ubuntu-latest",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "__run",
|
||||||
|
"if": {
|
||||||
|
"type": 3,
|
||||||
|
"expr": "success()"
|
||||||
|
},
|
||||||
|
"run": "echo hi"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user