Compare commits
16 Commits
release-v0.3.50
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 77ed325c44 | |||
| 1372d6dec7 | |||
| a06de82217 | |||
| 36b909a32d | |||
| 9a8a94bd21 | |||
| 8aa246e9d9 | |||
| ffc3778653 | |||
| 38f730cdce | |||
| a810405967 | |||
| 840d04cea8 | |||
| 0446b065b0 | |||
| 763dff2018 | |||
| 0c9d817440 | |||
| cc316ab9de | |||
| d5670c383a | |||
| f62a0e189d |
@@ -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",
|
||||
"version": "0.3.50",
|
||||
"version": "0.3.54",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
|
||||
@@ -25,6 +25,7 @@ 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", () => {
|
||||
@@ -56,7 +57,7 @@ describe("FeatureFlags", () => {
|
||||
"blockScalarChompingWarning",
|
||||
"allowCaseFunction",
|
||||
"allowCopilotRequestsPermission",
|
||||
"allowServiceContainerCommand"
|
||||
"allowConcurrencyQueue"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,10 +42,10 @@ export interface ExperimentalFeatures {
|
||||
allowCopilotRequestsPermission?: boolean;
|
||||
|
||||
/**
|
||||
* Enable `entrypoint` and `command` keys in service containers (`jobs.<job_id>.services.*`).
|
||||
* Enable the queue property in workflow concurrency settings.
|
||||
* @default false
|
||||
*/
|
||||
allowServiceContainerCommand?: boolean;
|
||||
allowConcurrencyQueue?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +62,7 @@ const allFeatureKeys: ExperimentalFeatureKey[] = [
|
||||
"blockScalarChompingWarning",
|
||||
"allowCaseFunction",
|
||||
"allowCopilotRequestsPermission",
|
||||
"allowServiceContainerCommand"
|
||||
"allowConcurrencyQueue"
|
||||
];
|
||||
|
||||
export class FeatureFlags {
|
||||
|
||||
@@ -127,6 +127,7 @@ 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.50",
|
||||
"version": "0.3.54",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -48,8 +48,8 @@
|
||||
"actions-languageserver": "./bin/actions-languageserver"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.50",
|
||||
"@actions/workflow-parser": "^0.3.50",
|
||||
"@actions/languageservice": "^0.3.54",
|
||||
"@actions/workflow-parser": "^0.3.54",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.50",
|
||||
"version": "0.3.54",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -47,8 +47,8 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.50",
|
||||
"@actions/workflow-parser": "^0.3.50",
|
||||
"@actions/expressions": "^0.3.54",
|
||||
"@actions/workflow-parser": "^0.3.54",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
|
||||
@@ -1164,7 +1164,16 @@ jobs:
|
||||
`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"check_run_id",
|
||||
"container",
|
||||
"services",
|
||||
"status",
|
||||
"workflow_file_path",
|
||||
"workflow_ref",
|
||||
"workflow_repository",
|
||||
"workflow_sha"
|
||||
]);
|
||||
});
|
||||
|
||||
it("job context is suggested within a job output", async () => {
|
||||
|
||||
@@ -105,13 +105,6 @@
|
||||
"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).\""
|
||||
},
|
||||
@@ -225,6 +218,18 @@
|
||||
},
|
||||
"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,7 +29,6 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
|
||||
"graphql_url",
|
||||
"head_ref",
|
||||
"job",
|
||||
"job_workflow_sha",
|
||||
"path",
|
||||
"ref",
|
||||
"ref_name",
|
||||
|
||||
@@ -18,12 +18,16 @@ describe("job context", () => {
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns status and check_run_id when job has no container or services", () => {
|
||||
it("returns status, check_run_id, and workflow fields 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();
|
||||
});
|
||||
@@ -173,4 +177,21 @@ 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, and check_run_id.
|
||||
* Returns the job context with container, services, status, check_run_id, and workflow identity fields.
|
||||
*/
|
||||
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||
@@ -42,6 +42,12 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {FeatureFlags} from "@actions/expressions/features";
|
||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||
import {validate} from "./validate.js";
|
||||
import {createDocument} from "./test-utils/document.js";
|
||||
@@ -7,6 +8,10 @@ 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 () => {
|
||||
@@ -243,3 +248,186 @@ 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,6 +432,24 @@ 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,24 +1,18 @@
|
||||
import {FeatureFlags} from "@actions/expressions";
|
||||
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, ValidationConfig} from "./validate.js";
|
||||
import {validate} from "./validate.js";
|
||||
|
||||
registerLogger(new TestLogger());
|
||||
|
||||
const configWithFlag: ValidationConfig = {
|
||||
featureFlags: new FeatureFlags({allowServiceContainerCommand: true})
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("service container command/entrypoint", () => {
|
||||
describe("with feature flag enabled", () => {
|
||||
it("allows command in service container", async () => {
|
||||
const input = `
|
||||
it("allows command in service container", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
@@ -30,13 +24,13 @@ jobs:
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
const commandErrors = result.filter(d => d.message.includes("command"));
|
||||
expect(commandErrors).toEqual([]);
|
||||
});
|
||||
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 = `
|
||||
it("allows entrypoint in service container", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
@@ -48,13 +42,13 @@ jobs:
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
|
||||
expect(entrypointErrors).toEqual([]);
|
||||
});
|
||||
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 = `
|
||||
it("allows both command and entrypoint in service container", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
@@ -67,13 +61,13 @@ jobs:
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
const relevantErrors = result.filter(d => d.message.includes("command") || d.message.includes("entrypoint"));
|
||||
expect(relevantErrors).toEqual([]);
|
||||
});
|
||||
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 even with flag enabled", async () => {
|
||||
const input = `
|
||||
it("rejects command in job container", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
@@ -84,13 +78,13 @@ jobs:
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
const commandErrors = result.filter(d => d.message.includes("command"));
|
||||
expect(commandErrors.length).toBeGreaterThan(0);
|
||||
});
|
||||
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 even with flag enabled", async () => {
|
||||
const input = `
|
||||
it("rejects entrypoint in job container", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
jobs:
|
||||
build:
|
||||
@@ -101,47 +95,8 @@ jobs:
|
||||
steps:
|
||||
- run: echo hi
|
||||
`;
|
||||
const result = await validate(createDocument("wf.yaml", input), configWithFlag);
|
||||
const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
|
||||
expect(entrypointErrors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with feature flag disabled", () => {
|
||||
it("rejects 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.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects 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.length).toBeGreaterThan(0);
|
||||
});
|
||||
const result = await validate(createDocument("wf.yaml", input));
|
||||
const entrypointErrors = result.filter(d => d.message.includes("entrypoint"));
|
||||
expect(entrypointErrors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
|
||||
import {Expr} from "@actions/expressions/ast";
|
||||
import {TemplateParseResult, WorkflowTemplate, isBasicExpression, isMapping, isString} from "@actions/workflow-parser";
|
||||
import {
|
||||
TemplateParseResult,
|
||||
WorkflowTemplate,
|
||||
isBasicExpression,
|
||||
isBoolean,
|
||||
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";
|
||||
@@ -239,6 +246,11 @@ 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) {
|
||||
@@ -664,6 +676,55 @@ 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.
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.50"
|
||||
"version": "0.3.54"
|
||||
}
|
||||
Generated
+9
-9
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"expressions": {
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.50",
|
||||
"version": "0.3.54",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -396,11 +396,11 @@
|
||||
},
|
||||
"languageserver": {
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.50",
|
||||
"version": "0.3.54",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.50",
|
||||
"@actions/workflow-parser": "^0.3.50",
|
||||
"@actions/languageservice": "^0.3.54",
|
||||
"@actions/workflow-parser": "^0.3.54",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -927,11 +927,11 @@
|
||||
},
|
||||
"languageservice": {
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.50",
|
||||
"version": "0.3.54",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.50",
|
||||
"@actions/workflow-parser": "^0.3.50",
|
||||
"@actions/expressions": "^0.3.54",
|
||||
"@actions/workflow-parser": "^0.3.54",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
@@ -14020,10 +14020,10 @@
|
||||
},
|
||||
"workflow-parser": {
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.50",
|
||||
"version": "0.3.54",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.50",
|
||||
"@actions/expressions": "^0.3.54",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.50",
|
||||
"version": "0.3.54",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -48,7 +48,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.50",
|
||||
"@actions/expressions": "^0.3.54",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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 {ConcurrencySetting} from "../workflow-template.js";
|
||||
import {ConcurrencyQueue, 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;
|
||||
@@ -26,6 +28,11 @@ 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}`);
|
||||
}
|
||||
|
||||
@@ -146,15 +146,11 @@ export function convertToServiceContainer(context: TemplateContext, container: T
|
||||
|
||||
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
||||
const serviceList: Container[] = [];
|
||||
const flags = context.state.featureFlags as import("@actions/expressions/features").FeatureFlags | undefined;
|
||||
const useServiceContainer = flags?.isEnabled("allowServiceContainerCommand") ?? false;
|
||||
|
||||
const mapping = services.assertMapping("services");
|
||||
for (const service of mapping) {
|
||||
service.key.assertString("service key");
|
||||
const container = useServiceContainer
|
||||
? convertToServiceContainer(context, service.value)
|
||||
: convertToJobContainer(context, service.value);
|
||||
const container = convertToServiceContainer(context, service.value);
|
||||
if (container) {
|
||||
serviceList.push(container);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,12 @@ export type WorkflowTemplate = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ConcurrencyQueue = "single" | "max";
|
||||
|
||||
export type ConcurrencySetting = {
|
||||
group?: StringToken;
|
||||
cancelInProgress?: boolean;
|
||||
queue?: ConcurrencyQueue;
|
||||
};
|
||||
|
||||
export type ActionsEnvironmentReference = {
|
||||
|
||||
@@ -1644,11 +1644,15 @@
|
||||
},
|
||||
"security-events": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Code scanning and Dependabot alerts."
|
||||
"description": "Code scanning alerts."
|
||||
},
|
||||
"statuses": {
|
||||
"type": "permission-level-any",
|
||||
"description": "Commit statuses."
|
||||
},
|
||||
"vulnerability-alerts": {
|
||||
"type": "permission-level-read-or-no-access",
|
||||
"description": "Dependabot alerts."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2050,10 +2054,20 @@
|
||||
"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": [
|
||||
|
||||
+28
-1
@@ -25,7 +25,11 @@ jobs:
|
||||
concurrency:
|
||||
group: ref
|
||||
cancel-in-progress: ${{ github.ref }}
|
||||
|
||||
build5:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: deploy
|
||||
queue: max
|
||||
|
||||
---
|
||||
{
|
||||
@@ -141,6 +145,29 @@ 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user