Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c85997ad0d | |||
| 671f92dbc6 |
@@ -1,4 +1 @@
|
||||
* @actions/actions-vscode-reviewers
|
||||
|
||||
# Owners maintaining https://github.com/actions/runner-images
|
||||
/languageservice/src/value-providers/default.ts @actions/runner-images-writers @actions/actions-vscode-reviewers
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x, 24.x]
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -37,10 +37,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 24.x
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
node-version: "16"
|
||||
|
||||
- name: Bump version and push
|
||||
run: |
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
PKG_VERSION: "" # will be set in the workflow
|
||||
@@ -69,8 +69,9 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.x
|
||||
node-version: 22.x
|
||||
cache: "npm"
|
||||
scope: '@actions'
|
||||
|
||||
- name: Parse version from lerna.json
|
||||
run: |
|
||||
@@ -96,6 +97,13 @@ jobs:
|
||||
core.summary.addLink(`Release v${{ env.PKG_VERSION }}`, release.data.html_url);
|
||||
await core.summary.write();
|
||||
|
||||
- name: setup authentication
|
||||
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish packages
|
||||
run: |
|
||||
npx lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
||||
lerna publish ${{ env.PKG_VERSION }} --yes --no-git-reset --no-git-tag-version
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
+1
-2
@@ -3,5 +3,4 @@ dist
|
||||
*.md
|
||||
*.js
|
||||
*.json
|
||||
*.d.ts
|
||||
/.nx/workspace-data
|
||||
*.d.ts
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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",
|
||||
"version": "0.3.54",
|
||||
"version": "0.3.44",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -44,7 +44,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -35,6 +35,7 @@ export function complete(
|
||||
context: Dictionary,
|
||||
extensionFunctions: FunctionInfo[],
|
||||
functions?: Map<string, FunctionDefinition>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
featureFlags?: FeatureFlags
|
||||
): CompletionItem[] {
|
||||
// Lex
|
||||
@@ -66,7 +67,7 @@ export function complete(
|
||||
const result = contextKeys(context);
|
||||
|
||||
// Merge with functions
|
||||
result.push(...functionItems(extensionFunctions, featureFlags));
|
||||
result.push(...functionItems(extensionFunctions));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -91,15 +92,10 @@ export function complete(
|
||||
return contextKeys(result);
|
||||
}
|
||||
|
||||
function functionItems(extensionFunctions: FunctionInfo[], featureFlags?: FeatureFlags): CompletionItem[] {
|
||||
function functionItems(extensionFunctions: FunctionInfo[]): CompletionItem[] {
|
||||
const result: CompletionItem[] = [];
|
||||
const flags = featureFlags ?? new FeatureFlags();
|
||||
|
||||
for (const fdef of [...Object.values(wellKnownFunctions), ...extensionFunctions]) {
|
||||
// Filter out case function if feature is disabled
|
||||
if (fdef.name === "case" && !flags.isEnabled("allowCaseFunction")) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
label: fdef.name,
|
||||
description: fdef.description,
|
||||
|
||||
@@ -25,7 +25,6 @@ describe("FeatureFlags", () => {
|
||||
it("returns true when all is enabled", () => {
|
||||
const flags = new FeatureFlags({all: true});
|
||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||
expect(flags.isEnabled("allowConcurrencyQueue")).toBe(true);
|
||||
});
|
||||
|
||||
it("explicit feature flag takes precedence over all:true", () => {
|
||||
@@ -52,13 +51,7 @@ describe("FeatureFlags", () => {
|
||||
|
||||
it("returns all features when all is enabled", () => {
|
||||
const flags = new FeatureFlags({all: true});
|
||||
expect(flags.getEnabledFeatures()).toEqual([
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"allowCaseFunction",
|
||||
"allowCopilotRequestsPermission",
|
||||
"allowConcurrencyQueue"
|
||||
]);
|
||||
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix", "blockScalarChompingWarning"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,22 +30,11 @@ export interface ExperimentalFeatures {
|
||||
blockScalarChompingWarning?: boolean;
|
||||
|
||||
/**
|
||||
* Enable the case() function in expressions.
|
||||
* Enable improved container image validation that handles
|
||||
* expressions gracefully and validates empty/docker:// images.
|
||||
* @default false
|
||||
*/
|
||||
allowCaseFunction?: boolean;
|
||||
|
||||
/**
|
||||
* Enable the copilot-requests permission in workflow permissions.
|
||||
* @default false
|
||||
*/
|
||||
allowCopilotRequestsPermission?: boolean;
|
||||
|
||||
/**
|
||||
* Enable the queue property in workflow concurrency settings.
|
||||
* @default false
|
||||
*/
|
||||
allowConcurrencyQueue?: boolean;
|
||||
containerImageValidation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,9 +49,7 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
|
||||
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
||||
"missingInputsQuickfix",
|
||||
"blockScalarChompingWarning",
|
||||
"allowCaseFunction",
|
||||
"allowCopilotRequestsPermission",
|
||||
"allowConcurrencyQueue"
|
||||
"containerImageValidation"
|
||||
];
|
||||
|
||||
export class FeatureFlags {
|
||||
|
||||
@@ -127,7 +127,6 @@ initializationOptions: {
|
||||
|---------|-------------|
|
||||
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
|
||||
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
|
||||
| `allowConcurrencyQueue` | Enable the `concurrency.queue` workflow property |
|
||||
|
||||
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.54",
|
||||
"version": "0.3.44",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -48,8 +48,8 @@
|
||||
"actions-languageserver": "./bin/actions-languageserver"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.54",
|
||||
"@actions/workflow-parser": "^0.3.54",
|
||||
"@actions/languageservice": "^0.3.44",
|
||||
"@actions/workflow-parser": "^0.3.44",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -57,7 +57,7 @@
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
@@ -73,10 +73,9 @@
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"fetch-mock": "^9.11.0",
|
||||
"jest": "^29.0.3",
|
||||
"node-fetch": "^2.6.7",
|
||||
"prettier": "^2.8.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.54",
|
||||
"version": "0.3.44",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -47,15 +47,15 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.54",
|
||||
"@actions/workflow-parser": "^0.3.54",
|
||||
"@actions/expressions": "^0.3.44",
|
||||
"@actions/workflow-parser": "^0.3.44",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
"yaml": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
@@ -74,6 +74,6 @@
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {data, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {CompletionItem, CompletionItemKind, MarkupContent} from "vscode-languageserver-types";
|
||||
import {complete, getExpressionInput} from "./complete.js";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
@@ -69,8 +69,7 @@ describe("expressions", () => {
|
||||
it("single region", async () => {
|
||||
const input = "run-name: ${{ | }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
@@ -113,8 +112,7 @@ describe("expressions", () => {
|
||||
it("single region with existing input", async () => {
|
||||
const input = "run-name: ${{ g| }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
@@ -135,8 +133,7 @@ describe("expressions", () => {
|
||||
it("single region with existing condition", async () => {
|
||||
const input = "run-name: ${{ g| == 'test' }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
@@ -157,8 +154,7 @@ describe("expressions", () => {
|
||||
it("multiple regions with partial function", async () => {
|
||||
const input = "run-name: Run a ${{ inputs.test }} one-line script ${{ from|('test') == inputs.name }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
@@ -179,8 +175,7 @@ describe("expressions", () => {
|
||||
it("multiple regions - first region", async () => {
|
||||
const input = "run-name: test-${{ git| == 1 }}-${{ github.event }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
@@ -201,8 +196,7 @@ describe("expressions", () => {
|
||||
it("multiple regions", async () => {
|
||||
const input = "run-name: test-${{ github }}-${{ | }}";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
contextProviderConfig
|
||||
});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
@@ -1164,16 +1158,7 @@ jobs:
|
||||
`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"check_run_id",
|
||||
"container",
|
||||
"services",
|
||||
"status",
|
||||
"workflow_file_path",
|
||||
"workflow_ref",
|
||||
"workflow_repository",
|
||||
"workflow_sha"
|
||||
]);
|
||||
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
|
||||
});
|
||||
|
||||
it("job context is suggested within a job output", async () => {
|
||||
@@ -1190,8 +1175,7 @@ jobs:
|
||||
`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
contextProviderConfig,
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
contextProviderConfig
|
||||
});
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"env",
|
||||
|
||||
@@ -6,7 +6,6 @@ import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
||||
import {TestLogger} from "./test-utils/logger.js";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||
import {FeatureFlags} from "@actions/expressions/features";
|
||||
|
||||
registerLogger(new TestLogger());
|
||||
|
||||
@@ -20,8 +19,8 @@ describe("completion", () => {
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
// 28 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
||||
expect(result.length).toEqual(30);
|
||||
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
||||
expect(result.length).toEqual(14);
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("macos-latest");
|
||||
expect(labels).toContain("(switch to list)");
|
||||
@@ -60,7 +59,7 @@ jobs:
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(27);
|
||||
expect(result.length).toEqual(11);
|
||||
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("macos-latest");
|
||||
@@ -898,11 +897,9 @@ jobs:
|
||||
});
|
||||
|
||||
describe("expression completions", () => {
|
||||
it("include case function when enabled", async () => {
|
||||
it("includes case function", async () => {
|
||||
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: true})
|
||||
});
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
// Expression completions starting with 'c': case, contains
|
||||
@@ -910,143 +907,5 @@ jobs:
|
||||
expect(labels).toContain("case");
|
||||
expect(labels).toContain("contains");
|
||||
});
|
||||
|
||||
it("exclude case function when disabled", async () => {
|
||||
const input = "on: push\njobs:\n build:\n runs-on: ${{ c|";
|
||||
const result = await complete(...getPositionFromCursor(input), {
|
||||
featureFlags: new FeatureFlags({allowCaseFunction: false})
|
||||
});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
// Expression completions starting with 'c': contains
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).not.toContain("case");
|
||||
expect(labels).toContain("contains");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,8 +116,7 @@ export async function complete(
|
||||
config,
|
||||
{
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
featureFlags: config?.featureFlags
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
},
|
||||
true
|
||||
);
|
||||
@@ -164,14 +163,6 @@ export async function complete(
|
||||
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
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
||||
values.push(...escapeHatches);
|
||||
|
||||
@@ -105,6 +105,13 @@
|
||||
"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`."
|
||||
},
|
||||
"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": {
|
||||
"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).\""
|
||||
},
|
||||
@@ -218,18 +225,6 @@
|
||||
},
|
||||
"check_run_id": {
|
||||
"description": "The unique identifier of the check run for this job."
|
||||
},
|
||||
"workflow_file_path": {
|
||||
"description": "The path of the workflow file that contains the job. For example, `.github/workflows/my-workflow.yml`."
|
||||
},
|
||||
"workflow_ref": {
|
||||
"description": "The ref path to the workflow file that contains the job. For example, `octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch`."
|
||||
},
|
||||
"workflow_repository": {
|
||||
"description": "The owner and repository name of the workflow file that contains the job. For example, `octocat/Hello-World`."
|
||||
},
|
||||
"workflow_sha": {
|
||||
"description": "The commit SHA of the workflow file that contains the job."
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
|
||||
@@ -29,6 +29,7 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
|
||||
"graphql_url",
|
||||
"head_ref",
|
||||
"job",
|
||||
"job_workflow_sha",
|
||||
"path",
|
||||
"ref",
|
||||
"ref_name",
|
||||
|
||||
@@ -18,16 +18,12 @@ describe("job context", () => {
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns status, check_run_id, and workflow fields when job has no container or services", () => {
|
||||
it("returns status and check_run_id when job has no container or services", () => {
|
||||
const workflowContext = {job: {}} as WorkflowContext;
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
expect(context.get("status")).toBeDefined();
|
||||
expect(context.get("check_run_id")).toBeDefined();
|
||||
expect(context.get("workflow_ref")).toBeDefined();
|
||||
expect(context.get("workflow_sha")).toBeDefined();
|
||||
expect(context.get("workflow_repository")).toBeDefined();
|
||||
expect(context.get("workflow_file_path")).toBeDefined();
|
||||
expect(context.get("container")).toBeUndefined();
|
||||
expect(context.get("services")).toBeUndefined();
|
||||
});
|
||||
@@ -177,21 +173,4 @@ describe("job context", () => {
|
||||
expect(redis.getDescription("ports")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow context fields", () => {
|
||||
it("includes workflow context fields with descriptions", () => {
|
||||
const workflowContext = {job: {}} as WorkflowContext;
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
expect(context.get("workflow_ref")).toBeDefined();
|
||||
expect(context.get("workflow_sha")).toBeDefined();
|
||||
expect(context.get("workflow_repository")).toBeDefined();
|
||||
expect(context.get("workflow_file_path")).toBeDefined();
|
||||
|
||||
expect(context.getDescription("workflow_ref")).toBeDefined();
|
||||
expect(context.getDescription("workflow_sha")).toBeDefined();
|
||||
expect(context.getDescription("workflow_repository")).toBeDefined();
|
||||
expect(context.getDescription("workflow_file_path")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getDescription} from "./descriptions.js";
|
||||
|
||||
/**
|
||||
* Returns the job context with container, services, status, check_run_id, and workflow identity fields.
|
||||
* Returns the job context with container, services, status, and check_run_id.
|
||||
*/
|
||||
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||
@@ -42,12 +42,6 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
||||
// Check run ID
|
||||
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
|
||||
|
||||
// Workflow context fields (populated at runtime for reusable workflow jobs)
|
||||
jobContext.add("workflow_file_path", new data.StringData(""), getDescription("job", "workflow_file_path"));
|
||||
jobContext.add("workflow_ref", new data.StringData(""), getDescription("job", "workflow_ref"));
|
||||
jobContext.add("workflow_repository", new data.StringData(""), getDescription("job", "workflow_repository"));
|
||||
jobContext.add("workflow_sha", new data.StringData(""), getDescription("job", "workflow_sha"));
|
||||
|
||||
return jobContext;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,9 +120,7 @@ jobs:
|
||||
`;
|
||||
const result = await hover(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result?.contents).toEqual(
|
||||
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
|
||||
);
|
||||
expect(result?.contents).toEqual("");
|
||||
});
|
||||
|
||||
it("on an invalid cron schedule", async () => {
|
||||
@@ -132,9 +130,7 @@ jobs:
|
||||
`;
|
||||
const result = await hover(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result?.contents).toEqual(
|
||||
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
|
||||
);
|
||||
expect(result?.contents).toEqual("");
|
||||
});
|
||||
|
||||
it("shows context inherited from parent nodes", async () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {FeatureFlags} from "@actions/expressions/features";
|
||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {validate} from "./validate.js";
|
||||
import {createDocument} from "./test-utils/document.js";
|
||||
@@ -8,10 +7,6 @@ beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
const queueValidationConfig = {
|
||||
featureFlags: new FeatureFlags({allowConcurrencyQueue: true})
|
||||
};
|
||||
|
||||
describe("validate concurrency deadlock", () => {
|
||||
describe("should error on matching concurrency groups", () => {
|
||||
it("simple string match", async () => {
|
||||
@@ -248,186 +243,3 @@ jobs:
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate concurrency queue + cancel-in-progress conflict", () => {
|
||||
describe("should error", () => {
|
||||
it("workflow-level queue: max with cancel-in-progress: true", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: true
|
||||
queue: max
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
|
||||
|
||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
||||
expect(queueErrors).toHaveLength(1);
|
||||
expect(queueErrors[0]).toMatchObject({
|
||||
message: "'queue: max' cannot be combined with 'cancel-in-progress: true'.",
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
});
|
||||
|
||||
it("job-level queue: max with cancel-in-progress: true", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: true
|
||||
queue: max
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
|
||||
|
||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
||||
expect(queueErrors).toHaveLength(1);
|
||||
expect(queueErrors[0]).toMatchObject({
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
});
|
||||
|
||||
it("both workflow and job level have the conflict", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: true
|
||||
queue: max
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: build
|
||||
cancel-in-progress: true
|
||||
queue: max
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input), queueValidationConfig);
|
||||
|
||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
||||
expect(queueErrors).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("should not error", () => {
|
||||
it("queue: max without cancel-in-progress", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: deploy
|
||||
queue: max
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
||||
expect(queueErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("queue: single with cancel-in-progress: true", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: true
|
||||
queue: single
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
||||
expect(queueErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("cancel-in-progress: false with queue: max", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
||||
expect(queueErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no queue property", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
||||
expect(queueErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("string form concurrency (no mapping)", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency: deploy
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const queueErrors = result.filter(d => d.message.includes("queue: max"));
|
||||
expect(queueErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not report queue conflict when the feature is disabled", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
concurrency:
|
||||
group: deploy
|
||||
cancel-in-progress: true
|
||||
queue: max
|
||||
jobs:
|
||||
job1:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi`;
|
||||
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
const queueConflictErrors = result.filter(d => d.message.includes("queue: max"));
|
||||
expect(queueConflictErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -432,24 +432,6 @@ jobs:
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("job.workflow_* fields", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo \${{ job.workflow_ref }}
|
||||
- run: echo \${{ job.workflow_sha }}
|
||||
- run: echo \${{ job.workflow_repository }}
|
||||
- run: echo \${{ job.workflow_file_path }}
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("job.services.<service_id>", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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,24 +368,6 @@ 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", () => {
|
||||
it("allows empty string in choice options", async () => {
|
||||
const result = await validate(
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
|
||||
import {Expr} from "@actions/expressions/ast";
|
||||
import {
|
||||
TemplateParseResult,
|
||||
WorkflowTemplate,
|
||||
isBasicExpression,
|
||||
isBoolean,
|
||||
isMapping,
|
||||
isString
|
||||
} from "@actions/workflow-parser";
|
||||
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
||||
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
||||
@@ -91,8 +84,7 @@ async function validateWorkflow(textDocument: TextDocument, config?: ValidationC
|
||||
// Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information
|
||||
const template = await getOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion,
|
||||
featureFlags: config?.featureFlags
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
// Validate expressions and value providers
|
||||
@@ -246,11 +238,6 @@ async function additionalValidations(
|
||||
|
||||
// Validate concurrency deadlock between workflow and job levels
|
||||
validateConcurrencyDeadlock(diagnostics, template);
|
||||
|
||||
// Validate incompatible concurrency options
|
||||
if (featureFlags?.isEnabled("allowConcurrencyQueue")) {
|
||||
validateConcurrencyQueueCancelInProgress(diagnostics, template);
|
||||
}
|
||||
}
|
||||
|
||||
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
|
||||
@@ -676,55 +663,6 @@ function validateConcurrencyDeadlock(diagnostics: Diagnostic[], template: Workfl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that `queue: max` and `cancel-in-progress: true` are not both set
|
||||
* in a concurrency mapping, as this combination is invalid.
|
||||
*/
|
||||
function validateConcurrencyQueueCancelInProgress(diagnostics: Diagnostic[], template: WorkflowTemplate): void {
|
||||
// Check workflow-level concurrency
|
||||
if (template.concurrency) {
|
||||
checkConcurrencyQueueConflict(diagnostics, template.concurrency);
|
||||
}
|
||||
|
||||
// Check job-level concurrency
|
||||
for (const job of template.jobs || []) {
|
||||
if (job.concurrency) {
|
||||
checkConcurrencyQueueConflict(diagnostics, job.concurrency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkConcurrencyQueueConflict(diagnostics: Diagnostic[], token: TemplateToken): void {
|
||||
if (!isMapping(token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hasQueueMax = false;
|
||||
let hasCancelInProgressTrue = false;
|
||||
let queueRange: TokenRange | undefined;
|
||||
|
||||
for (const pair of token) {
|
||||
if (!isString(pair.key) || pair.key.isExpression || pair.value.isExpression) {
|
||||
continue;
|
||||
}
|
||||
if (pair.key.value === "queue" && isString(pair.value) && pair.value.value === "max") {
|
||||
hasQueueMax = true;
|
||||
queueRange = pair.key.range;
|
||||
}
|
||||
if (pair.key.value === "cancel-in-progress" && isBoolean(pair.value) && pair.value.value) {
|
||||
hasCancelInProgressTrue = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasQueueMax && hasCancelInProgressTrue && queueRange) {
|
||||
diagnostics.push({
|
||||
message: "'queue: max' cannot be combined with 'cancel-in-progress: true'.",
|
||||
range: mapRange(queueRange),
|
||||
severity: DiagnosticSeverity.Error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the static concurrency group name from a concurrency token.
|
||||
* Returns undefined if the token is an expression or doesn't have a static group.
|
||||
|
||||
@@ -4,36 +4,19 @@ import {reusableJobInputs} from "./reusable-job-inputs.js";
|
||||
import {reusableJobSecrets} from "./reusable-job-secrets.js";
|
||||
import {stringsToValues} from "./strings-to-values.js";
|
||||
|
||||
// Refer to: https://github.com/actions/runner-images?tab=readme-ov-file#available-images
|
||||
export const DEFAULT_RUNNER_LABELS = [
|
||||
"codespaces-prebuild",
|
||||
"macos-13",
|
||||
"macos-13-large",
|
||||
"macos-13-xlarge",
|
||||
"macos-14",
|
||||
"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-24.04",
|
||||
"ubuntu-22.04",
|
||||
"ubuntu-20.04",
|
||||
"ubuntu-slim",
|
||||
"windows-latest",
|
||||
"windows-2022",
|
||||
"windows-2025",
|
||||
"windows-2025-vs2026",
|
||||
"windows-latest"
|
||||
"windows-2019",
|
||||
"macos-latest",
|
||||
"macos-15",
|
||||
"macos-14",
|
||||
"self-hosted"
|
||||
];
|
||||
|
||||
const runsOnValueProvider = {
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.54"
|
||||
"version": "0.3.44"
|
||||
}
|
||||
Generated
+1736
-2397
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -9,7 +9,10 @@
|
||||
"./languageserver"
|
||||
],
|
||||
"devDependencies": {
|
||||
"lerna": "^9.0.0",
|
||||
"lerna": "^8.2.2",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"overrides": {
|
||||
"typescript": "$typescript"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.54",
|
||||
"version": "0.3.44",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -48,12 +48,12 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.54",
|
||||
"@actions/expressions": "^0.3.44",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
@@ -69,6 +69,6 @@
|
||||
"prettier": "^2.8.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,86 +578,4 @@ 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,4 +1,4 @@
|
||||
import {FeatureFlags} from "@actions/expressions/features";
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {TemplateContext} from "../templates/template-context.js";
|
||||
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
|
||||
import {FileProvider} from "../workflows/file-provider.js";
|
||||
@@ -41,15 +41,15 @@ export type WorkflowTemplateConverterOptions = {
|
||||
|
||||
/**
|
||||
* Feature flags for experimental features.
|
||||
* When not provided, all experimental features are disabled.
|
||||
*/
|
||||
featureFlags?: FeatureFlags;
|
||||
};
|
||||
|
||||
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
|
||||
const defaultOptions: Omit<Required<WorkflowTemplateConverterOptions>, "featureFlags"> = {
|
||||
maxReusableWorkflowDepth: 4,
|
||||
fetchReusableWorkflowDepth: 0,
|
||||
errorPolicy: ErrorPolicy.ReturnErrorsOnly,
|
||||
featureFlags: new FeatureFlags()
|
||||
errorPolicy: ErrorPolicy.ReturnErrorsOnly
|
||||
};
|
||||
|
||||
export async function convertWorkflowTemplate(
|
||||
@@ -61,7 +61,10 @@ export async function convertWorkflowTemplate(
|
||||
const result = {} as WorkflowTemplate;
|
||||
const opts = getOptionsWithDefaults(options);
|
||||
|
||||
context.state.featureFlags = opts.featureFlags;
|
||||
// Store feature flags in context for converter functions
|
||||
if (options.featureFlags) {
|
||||
context.state["featureFlags"] = options.featureFlags;
|
||||
}
|
||||
|
||||
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
||||
result.errors = context.errors.getErrors().map(x => ({
|
||||
@@ -141,7 +144,9 @@ export async function convertWorkflowTemplate(
|
||||
return result;
|
||||
}
|
||||
|
||||
function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Required<WorkflowTemplateConverterOptions> {
|
||||
function getOptionsWithDefaults(
|
||||
options: WorkflowTemplateConverterOptions
|
||||
): Omit<Required<WorkflowTemplateConverterOptions>, "featureFlags"> {
|
||||
return {
|
||||
maxReusableWorkflowDepth:
|
||||
options.maxReusableWorkflowDepth !== undefined
|
||||
@@ -151,7 +156,6 @@ function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Requ
|
||||
options.fetchReusableWorkflowDepth !== undefined
|
||||
? options.fetchReusableWorkflowDepth
|
||||
: defaultOptions.fetchReusableWorkflowDepth,
|
||||
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy,
|
||||
featureFlags: options.featureFlags ?? defaultOptions.featureFlags
|
||||
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type {FeatureFlags} from "@actions/expressions/features";
|
||||
import {TemplateContext} from "../../templates/template-context.js";
|
||||
import {TemplateToken} from "../../templates/tokens/template-token.js";
|
||||
import {isString} from "../../templates/tokens/type-guards.js";
|
||||
import {ConcurrencyQueue, ConcurrencySetting} from "../workflow-template.js";
|
||||
import {ConcurrencySetting} from "../workflow-template.js";
|
||||
|
||||
export function convertConcurrency(context: TemplateContext, token: TemplateToken): ConcurrencySetting {
|
||||
const result: ConcurrencySetting = {};
|
||||
const featureFlags = context.state.featureFlags as FeatureFlags | undefined;
|
||||
|
||||
if (token.isExpression) {
|
||||
return result;
|
||||
@@ -28,11 +26,6 @@ export function convertConcurrency(context: TemplateContext, token: TemplateToke
|
||||
case "cancel-in-progress":
|
||||
result.cancelInProgress = property.value.assertBoolean("cancel-in-progress").value;
|
||||
break;
|
||||
case "queue":
|
||||
if (featureFlags?.isEnabled("allowConcurrencyQueue")) {
|
||||
result.queue = property.value.assertString("queue").value as ConcurrencyQueue;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
context.error(propertyName, `Invalid property name: ${propertyName.value}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {nullTrace} from "../../test-utils/null-trace.js";
|
||||
import {parseWorkflow} from "../../workflows/workflow-parser.js";
|
||||
import {convertWorkflowTemplate, ErrorPolicy} from "../convert.js";
|
||||
|
||||
// Minimal FeatureFlags-compatible object for tests
|
||||
const featureFlags = {isEnabled: (f: string) => f === "containerImageValidation"};
|
||||
|
||||
async function getErrors(content: string): Promise<string[]> {
|
||||
const result = parseWorkflow({name: "wf.yaml", content}, nullTrace);
|
||||
result.context.state["featureFlags"] = featureFlags;
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
return (template.errors ?? []).map((e: {Message: string}) => e.Message);
|
||||
}
|
||||
|
||||
function expectNoContainerErrors(errors: string[]): void {
|
||||
const containerErrors = errors.filter(e => e.includes("Container image"));
|
||||
expect(containerErrors).toHaveLength(0);
|
||||
}
|
||||
|
||||
function expectContainerError(errors: string[], count = 1): void {
|
||||
const containerErrors = errors.filter(e => e.includes("Container image cannot be empty"));
|
||||
expect(containerErrors).toHaveLength(count);
|
||||
}
|
||||
|
||||
describe("container image validation", () => {
|
||||
describe("shorthand form", () => {
|
||||
it("container: '' is silent for job container", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container: ''
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("container: docker:// errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container: docker://
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("container: valid-image passes", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container: ubuntu:16.04
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapping form", () => {
|
||||
it("container image: '' errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: ''
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("container image: docker:// errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: docker://
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("container: {} (empty object, missing image) errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container: {}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("container image: null errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image:
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("empty image with expression in other field still errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: ''
|
||||
options: \${{ matrix.opts }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
});
|
||||
|
||||
describe("services shorthand", () => {
|
||||
it("services svc: '' errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc: ''
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("services svc: docker:// errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc: docker://
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
});
|
||||
|
||||
describe("services mapping", () => {
|
||||
it("services svc image: '' errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc:
|
||||
image: ''
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("services svc image: docker:// errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc:
|
||||
image: docker://
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("services svc: {} (empty object) errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc: {}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
|
||||
it("empty image with expression sibling service still errors", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
svc1:
|
||||
image: ''
|
||||
svc2: \${{ matrix.svc }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectContainerError(errors);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expression safety", () => {
|
||||
it("container: expression skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container: \${{ matrix.container }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("container image: expression skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: \${{ matrix.image }}
|
||||
options: --privileged
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("container with expression key skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
\${{ vars.KEY }}: ubuntu
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("services: expression skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services: \${{ fromJSON(inputs.services) }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("services with expression alias key skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
\${{ matrix.alias }}: postgres
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("services container with expression key skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
db:
|
||||
\${{ vars.KEY }}: postgres
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("container with all expression fields skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
container:
|
||||
image: \${{ matrix.image }}
|
||||
options: \${{ matrix.options }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("services svc: expression skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
db: \${{ matrix.db }}
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
|
||||
it("services image: expression skips validation", async () => {
|
||||
const errors = await getErrors(`on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux
|
||||
services:
|
||||
db:
|
||||
image: \${{ matrix.db_image }}
|
||||
options: --health-cmd pg_isready
|
||||
steps:
|
||||
- run: echo hi`);
|
||||
expectNoContainerErrors(errors);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,199 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
import {TemplateContext} from "../../templates/template-context.js";
|
||||
import {MappingToken, SequenceToken, StringToken, TemplateToken} from "../../templates/tokens/index.js";
|
||||
import {isString} from "../../templates/tokens/type-guards.js";
|
||||
import {Container, Credential} from "../workflow-template.js";
|
||||
|
||||
export function convertToJobContainer(context: TemplateContext, container: TemplateToken): Container | undefined {
|
||||
function getFeatureFlags(context: TemplateContext): FeatureFlags | undefined {
|
||||
return context.state["featureFlags"] as FeatureFlags | undefined;
|
||||
}
|
||||
|
||||
const DOCKER_URI_PREFIX = "docker://";
|
||||
|
||||
function isEmptyImage(value: string): boolean {
|
||||
const trimmed = value.startsWith(DOCKER_URI_PREFIX) ? value.substring(DOCKER_URI_PREFIX.length) : value;
|
||||
return trimmed.length === 0;
|
||||
}
|
||||
|
||||
export function convertToJobContainer(
|
||||
context: TemplateContext,
|
||||
container: TemplateToken,
|
||||
isServiceContainer = false
|
||||
): Container | undefined {
|
||||
// Feature flag guard — use legacy implementation when flag is off
|
||||
if (!getFeatureFlags(context)?.isEnabled("containerImageValidation")) {
|
||||
return convertToJobContainerLegacy(context, container);
|
||||
}
|
||||
|
||||
if (container.isExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Shorthand form
|
||||
if (isString(container)) {
|
||||
const image = container.assertString("container item");
|
||||
if (!image || image.value.length === 0) {
|
||||
if (isServiceContainer) {
|
||||
context.error(container, "Container image cannot be empty");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmptyImage(image.value)) {
|
||||
context.error(container, "Container image cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
return {image};
|
||||
}
|
||||
|
||||
// Mapping form
|
||||
const mapping = container.assertMapping("container item");
|
||||
if (!mapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
let image: StringToken | undefined;
|
||||
let env: MappingToken | undefined;
|
||||
let ports: SequenceToken | undefined;
|
||||
let volumes: SequenceToken | undefined;
|
||||
let options: StringToken | undefined;
|
||||
let credentials: Credential | undefined;
|
||||
let hasExpressionKey = false;
|
||||
let hasExpression = false;
|
||||
|
||||
for (const item of mapping) {
|
||||
if (item.key.isExpression) {
|
||||
hasExpressionKey = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = item.key.assertString("container item key");
|
||||
|
||||
switch (key.value) {
|
||||
case "image":
|
||||
if (item.value.isExpression) {
|
||||
hasExpression = true;
|
||||
break;
|
||||
}
|
||||
image = item.value.assertString("container image");
|
||||
break;
|
||||
case "credentials":
|
||||
if (!item.value.isExpression) {
|
||||
credentials = convertCredentials(context, item.value);
|
||||
}
|
||||
break;
|
||||
case "env":
|
||||
if (!item.value.isExpression) {
|
||||
env = item.value.assertMapping("container env");
|
||||
}
|
||||
break;
|
||||
case "ports":
|
||||
if (!item.value.isExpression) {
|
||||
ports = item.value.assertSequence("container ports");
|
||||
}
|
||||
break;
|
||||
case "volumes":
|
||||
if (!item.value.isExpression) {
|
||||
volumes = item.value.assertSequence("container volumes");
|
||||
}
|
||||
break;
|
||||
case "options":
|
||||
if (!item.value.isExpression) {
|
||||
options = item.value.assertString("container options");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
context.error(key, `Unexpected container item key: ${key.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate image
|
||||
if (image) {
|
||||
if (isEmptyImage(image.value)) {
|
||||
context.error(image, "Container image cannot be empty");
|
||||
return;
|
||||
}
|
||||
return {image, credentials, env, ports, volumes, options};
|
||||
}
|
||||
|
||||
// No image key — skip error if expression keys could provide one
|
||||
if (!hasExpressionKey && !hasExpression) {
|
||||
context.error(container, "Container image cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
||||
// Feature flag guard — use legacy implementation when flag is off
|
||||
if (!getFeatureFlags(context)?.isEnabled("containerImageValidation")) {
|
||||
return convertToJobServicesLegacy(context, services);
|
||||
}
|
||||
|
||||
if (services.isExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceList: Container[] = [];
|
||||
const mapping = services.assertMapping("services");
|
||||
|
||||
for (const service of mapping) {
|
||||
if (service.key.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
service.key.assertString("service key");
|
||||
const container = convertToJobContainer(context, service.value, true);
|
||||
if (container) {
|
||||
serviceList.push(container);
|
||||
}
|
||||
}
|
||||
|
||||
return serviceList;
|
||||
}
|
||||
|
||||
function convertCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
|
||||
const mapping = value.assertMapping("credentials");
|
||||
if (!mapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
let username: StringToken | undefined;
|
||||
let password: StringToken | undefined;
|
||||
|
||||
for (const item of mapping) {
|
||||
if (item.key.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = item.key.assertString("credentials item");
|
||||
if (item.value.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (key.value) {
|
||||
case "username":
|
||||
username = item.value.assertString("credentials username");
|
||||
break;
|
||||
case "password":
|
||||
password = item.value.assertString("credentials password");
|
||||
break;
|
||||
default:
|
||||
context.error(key, `credentials key ${key.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {username, password};
|
||||
}
|
||||
|
||||
// ===== Legacy implementations (remove when containerImageValidation graduates) =====
|
||||
|
||||
function convertToJobContainerLegacy(context: TemplateContext, container: TemplateToken): Container | undefined {
|
||||
let image: StringToken | undefined;
|
||||
let env: MappingToken | undefined;
|
||||
let ports: SequenceToken | undefined;
|
||||
let volumes: SequenceToken | undefined;
|
||||
let options: StringToken | undefined;
|
||||
|
||||
// Skip validation for expressions for now to match
|
||||
// behavior of the other parsers
|
||||
for (const [, token] of TemplateToken.traverse(container)) {
|
||||
if (token.isExpression) {
|
||||
return;
|
||||
@@ -19,7 +201,6 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
|
||||
}
|
||||
|
||||
if (isString(container)) {
|
||||
// Workflow uses shorthand syntax `container: image-name`
|
||||
image = container.assertString("container item");
|
||||
return {image: image};
|
||||
}
|
||||
@@ -35,7 +216,7 @@ export function convertToJobContainer(context: TemplateContext, container: Templ
|
||||
image = value.assertString("container image");
|
||||
break;
|
||||
case "credentials":
|
||||
convertToJobCredentials(context, value);
|
||||
convertToJobCredentialsLegacy(context, value);
|
||||
break;
|
||||
case "env":
|
||||
env = value.assertMapping("container env");
|
||||
@@ -70,87 +251,13 @@ 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 {
|
||||
function convertToJobServicesLegacy(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
||||
const serviceList: Container[] = [];
|
||||
|
||||
const mapping = services.assertMapping("services");
|
||||
for (const service of mapping) {
|
||||
service.key.assertString("service key");
|
||||
const container = convertToServiceContainer(context, service.value);
|
||||
const container = convertToJobContainerLegacy(context, service.value);
|
||||
if (container) {
|
||||
serviceList.push(container);
|
||||
}
|
||||
@@ -158,7 +265,7 @@ export function convertToJobServices(context: TemplateContext, services: Templat
|
||||
return serviceList;
|
||||
}
|
||||
|
||||
function convertToJobCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
|
||||
function convertToJobCredentialsLegacy(context: TemplateContext, value: TemplateToken): Credential | undefined {
|
||||
const mapping = value.assertMapping("credentials");
|
||||
|
||||
let username: StringToken | undefined;
|
||||
|
||||
@@ -149,34 +149,23 @@ function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig & Vers
|
||||
|
||||
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
|
||||
const result = [] as ScheduleConfig[];
|
||||
|
||||
for (const item of token) {
|
||||
const mappingToken = item.assertMapping(`event schedule`);
|
||||
const config: ScheduleConfig = {cron: ""};
|
||||
let valid = true;
|
||||
|
||||
for (const entry of mappingToken) {
|
||||
const key = entry.key.assertString(`schedule key`);
|
||||
|
||||
if (key.value === "cron") {
|
||||
const cron = entry.value.assertString(`schedule cron`);
|
||||
if (mappingToken.count == 1) {
|
||||
const schedule = mappingToken.get(0);
|
||||
const scheduleKey = schedule.key.assertString(`schedule key`);
|
||||
if (scheduleKey.value == "cron") {
|
||||
const cron = schedule.value.assertString(`schedule cron`);
|
||||
// Validate the cron string
|
||||
if (!isValidCron(cron.value)) {
|
||||
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
|
||||
}
|
||||
config.cron = cron.value;
|
||||
} else if (key.value === "timezone") {
|
||||
const timezone = entry.value.assertString(`schedule timezone`);
|
||||
config.timezone = timezone.value;
|
||||
result.push({cron: cron.value});
|
||||
} else {
|
||||
context.error(key, `Invalid schedule key`);
|
||||
valid = false;
|
||||
context.error(scheduleKey, `Invalid schedule key`);
|
||||
}
|
||||
}
|
||||
|
||||
if (valid && config.cron) {
|
||||
result.push(config);
|
||||
} else if (valid && !config.cron) {
|
||||
context.error(mappingToken, "Missing required key 'cron' in schedule entry");
|
||||
} else {
|
||||
context.error(mappingToken, "Invalid format for 'schedule'");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
|
||||
break;
|
||||
|
||||
case "container":
|
||||
convertToJobContainer(context, item.value);
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertToJobContainer(context, item.value));
|
||||
container = item.value;
|
||||
break;
|
||||
|
||||
@@ -103,7 +103,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
|
||||
break;
|
||||
|
||||
case "services":
|
||||
convertToJobServices(context, item.value);
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertToJobServices(context, item.value));
|
||||
services = item.value;
|
||||
break;
|
||||
|
||||
|
||||
@@ -34,14 +34,6 @@ export function convertToActionsEnvironmentRef(
|
||||
case "url":
|
||||
result.url = property.value;
|
||||
break;
|
||||
|
||||
case "deployment": {
|
||||
const deploymentValue = property.value.assertBoolean("job environment deployment");
|
||||
if (deploymentValue.value === false) {
|
||||
result.skipDeployment = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,18 +18,14 @@ export type WorkflowTemplate = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ConcurrencyQueue = "single" | "max";
|
||||
|
||||
export type ConcurrencySetting = {
|
||||
group?: StringToken;
|
||||
cancelInProgress?: boolean;
|
||||
queue?: ConcurrencyQueue;
|
||||
};
|
||||
|
||||
export type ActionsEnvironmentReference = {
|
||||
name?: TemplateToken;
|
||||
url?: TemplateToken;
|
||||
skipDeployment?: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowJob = Job | ReusableWorkflowJob;
|
||||
@@ -78,8 +74,6 @@ export type Container = {
|
||||
ports?: SequenceToken;
|
||||
volumes?: SequenceToken;
|
||||
options?: StringToken;
|
||||
entrypoint?: StringToken;
|
||||
command?: StringToken;
|
||||
};
|
||||
|
||||
export type Credential = {
|
||||
@@ -202,7 +196,6 @@ export type SecretConfig = {
|
||||
|
||||
export type ScheduleConfig = {
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export type WorkflowFilterConfig = {
|
||||
|
||||
@@ -1602,10 +1602,6 @@
|
||||
"type": "permission-level-any",
|
||||
"description": "Repository contents, commits, branches, downloads, releases, and merges."
|
||||
},
|
||||
"copilot-requests": {
|
||||
"type": "permission-level-write-or-no-access",
|
||||
"description": "GitHub Copilot requests."
|
||||
},
|
||||
"deployments": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Deployments and deployment statuses."
|
||||
@@ -1644,15 +1640,11 @@
|
||||
},
|
||||
"security-events": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Code scanning alerts."
|
||||
"description": "Code scanning and Dependabot alerts."
|
||||
},
|
||||
"statuses": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Commit statuses."
|
||||
},
|
||||
"vulnerability-alerts": {
|
||||
"type": "permission-level-read-or-no-access",
|
||||
"description": "Dependabot alerts."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2054,20 +2046,10 @@
|
||||
"cancel-in-progress": {
|
||||
"type": "boolean",
|
||||
"description": "To cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true."
|
||||
},
|
||||
"queue": {
|
||||
"type": "concurrency-queue",
|
||||
"description": "The queuing mode for the concurrency group. When set to `max`, workflows or jobs will wait in a queue for the concurrency group up to the maximum queue length. Default: `single` meaning at most one item can be pending."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"concurrency-queue": {
|
||||
"allowed-values": [
|
||||
"single",
|
||||
"max"
|
||||
]
|
||||
},
|
||||
"job-environment": {
|
||||
"description": "The environment that the job references. All environment protection rules must pass before a job referencing the environment is sent to a runner.",
|
||||
"context": [
|
||||
@@ -2093,10 +2075,6 @@
|
||||
"url": {
|
||||
"type": "string-runner-context-no-secrets",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2367,7 +2345,7 @@
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "non-empty-string",
|
||||
"type": "string",
|
||||
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
|
||||
},
|
||||
"options": {
|
||||
@@ -2412,8 +2390,8 @@
|
||||
"matrix"
|
||||
],
|
||||
"one-of": [
|
||||
"non-empty-string",
|
||||
"service-container-mapping"
|
||||
"string",
|
||||
"container-mapping"
|
||||
]
|
||||
},
|
||||
"container-registry-credentials": {
|
||||
@@ -2642,57 +2620,14 @@
|
||||
"cron-mapping": {
|
||||
"mapping": {
|
||||
"properties": {
|
||||
"cron": {
|
||||
"type": "cron-pattern",
|
||||
"required": true
|
||||
},
|
||||
"timezone": "timezone-string"
|
||||
"cron": "cron-pattern"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cron-pattern": {
|
||||
"description": "A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes.",
|
||||
"string": {
|
||||
"require-non-empty": true
|
||||
}
|
||||
},
|
||||
"timezone-string": {
|
||||
"description": "A string that represents the time zone a scheduled workflow will run relative to in IANA format (e.g. 'America/New_York' or 'Europe/London'). If omitted, the workflow will run relative to midnight UTC.",
|
||||
"string": {
|
||||
"require-non-empty": true
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-28
@@ -25,11 +25,7 @@ jobs:
|
||||
concurrency:
|
||||
group: ref
|
||||
cancel-in-progress: ${{ github.ref }}
|
||||
build5:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: deploy
|
||||
queue: max
|
||||
|
||||
|
||||
---
|
||||
{
|
||||
@@ -145,29 +141,6 @@ jobs:
|
||||
]
|
||||
},
|
||||
"runs-on": "macos-latest"
|
||||
},
|
||||
{
|
||||
"type": "job",
|
||||
"id": "build5",
|
||||
"name": "build5",
|
||||
"if": {
|
||||
"type": 3,
|
||||
"expr": "success()"
|
||||
},
|
||||
"concurrency": {
|
||||
"type": 2,
|
||||
"map": [
|
||||
{
|
||||
"Key": "group",
|
||||
"Value": "deploy"
|
||||
},
|
||||
{
|
||||
"Key": "queue",
|
||||
"Value": "max"
|
||||
}
|
||||
]
|
||||
},
|
||||
"runs-on": "ubuntu-latest"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
@@ -91,3 +91,4 @@ yaml-schema-sequence.yml
|
||||
yaml-schema-str-flow-styles.yml
|
||||
yaml-schema-string.yml
|
||||
yaml-schema-timestamp.yml
|
||||
job-container-invalid.yml
|
||||
|
||||
Reference in New Issue
Block a user