Add concurrency queue support (#355)
This commit is contained in:
@@ -25,6 +25,7 @@ describe("FeatureFlags", () => {
|
|||||||
it("returns true when all is enabled", () => {
|
it("returns true when all is enabled", () => {
|
||||||
const flags = new FeatureFlags({all: true});
|
const flags = new FeatureFlags({all: true});
|
||||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
||||||
|
expect(flags.isEnabled("allowConcurrencyQueue")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("explicit feature flag takes precedence over all:true", () => {
|
it("explicit feature flag takes precedence over all:true", () => {
|
||||||
@@ -55,7 +56,8 @@ describe("FeatureFlags", () => {
|
|||||||
"missingInputsQuickfix",
|
"missingInputsQuickfix",
|
||||||
"blockScalarChompingWarning",
|
"blockScalarChompingWarning",
|
||||||
"allowCaseFunction",
|
"allowCaseFunction",
|
||||||
"allowCopilotRequestsPermission"
|
"allowCopilotRequestsPermission",
|
||||||
|
"allowConcurrencyQueue"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export interface ExperimentalFeatures {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
allowCopilotRequestsPermission?: boolean;
|
allowCopilotRequestsPermission?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the queue property in workflow concurrency settings.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
allowConcurrencyQueue?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,7 +61,8 @@ const allFeatureKeys: ExperimentalFeatureKey[] = [
|
|||||||
"missingInputsQuickfix",
|
"missingInputsQuickfix",
|
||||||
"blockScalarChompingWarning",
|
"blockScalarChompingWarning",
|
||||||
"allowCaseFunction",
|
"allowCaseFunction",
|
||||||
"allowCopilotRequestsPermission"
|
"allowCopilotRequestsPermission",
|
||||||
|
"allowConcurrencyQueue"
|
||||||
];
|
];
|
||||||
|
|
||||||
export class FeatureFlags {
|
export class FeatureFlags {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ initializationOptions: {
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
|
| `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 |
|
| `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`.
|
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {FeatureFlags} from "@actions/expressions/features";
|
||||||
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
import {DiagnosticSeverity} from "vscode-languageserver-types";
|
||||||
import {validate} from "./validate.js";
|
import {validate} from "./validate.js";
|
||||||
import {createDocument} from "./test-utils/document.js";
|
import {createDocument} from "./test-utils/document.js";
|
||||||
@@ -7,6 +8,10 @@ beforeEach(() => {
|
|||||||
clearCache();
|
clearCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queueValidationConfig = {
|
||||||
|
featureFlags: new FeatureFlags({allowConcurrencyQueue: true})
|
||||||
|
};
|
||||||
|
|
||||||
describe("validate concurrency deadlock", () => {
|
describe("validate concurrency deadlock", () => {
|
||||||
describe("should error on matching concurrency groups", () => {
|
describe("should error on matching concurrency groups", () => {
|
||||||
it("simple string match", async () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
|
import {FeatureFlags, Lexer, Parser} from "@actions/expressions";
|
||||||
import {Expr} from "@actions/expressions/ast";
|
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 {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||||
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
|
||||||
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
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
|
// Validate concurrency deadlock between workflow and job levels
|
||||||
validateConcurrencyDeadlock(diagnostics, template);
|
validateConcurrencyDeadlock(diagnostics, template);
|
||||||
|
|
||||||
|
// Validate incompatible concurrency options
|
||||||
|
if (featureFlags?.isEnabled("allowConcurrencyQueue")) {
|
||||||
|
validateConcurrencyQueueCancelInProgress(diagnostics, template);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
|
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.
|
* 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.
|
* Returns undefined if the token is an expression or doesn't have a static group.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import type {FeatureFlags} from "@actions/expressions/features";
|
||||||
import {TemplateContext} from "../../templates/template-context.js";
|
import {TemplateContext} from "../../templates/template-context.js";
|
||||||
import {TemplateToken} from "../../templates/tokens/template-token.js";
|
import {TemplateToken} from "../../templates/tokens/template-token.js";
|
||||||
import {isString} from "../../templates/tokens/type-guards.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 {
|
export function convertConcurrency(context: TemplateContext, token: TemplateToken): ConcurrencySetting {
|
||||||
const result: ConcurrencySetting = {};
|
const result: ConcurrencySetting = {};
|
||||||
|
const featureFlags = context.state.featureFlags as FeatureFlags | undefined;
|
||||||
|
|
||||||
if (token.isExpression) {
|
if (token.isExpression) {
|
||||||
return result;
|
return result;
|
||||||
@@ -26,6 +28,11 @@ export function convertConcurrency(context: TemplateContext, token: TemplateToke
|
|||||||
case "cancel-in-progress":
|
case "cancel-in-progress":
|
||||||
result.cancelInProgress = property.value.assertBoolean("cancel-in-progress").value;
|
result.cancelInProgress = property.value.assertBoolean("cancel-in-progress").value;
|
||||||
break;
|
break;
|
||||||
|
case "queue":
|
||||||
|
if (featureFlags?.isEnabled("allowConcurrencyQueue")) {
|
||||||
|
result.queue = property.value.assertString("queue").value as ConcurrencyQueue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
context.error(propertyName, `Invalid property name: ${propertyName.value}`);
|
context.error(propertyName, `Invalid property name: ${propertyName.value}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ export type WorkflowTemplate = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConcurrencyQueue = "single" | "max";
|
||||||
|
|
||||||
export type ConcurrencySetting = {
|
export type ConcurrencySetting = {
|
||||||
group?: StringToken;
|
group?: StringToken;
|
||||||
cancelInProgress?: boolean;
|
cancelInProgress?: boolean;
|
||||||
|
queue?: ConcurrencyQueue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActionsEnvironmentReference = {
|
export type ActionsEnvironmentReference = {
|
||||||
|
|||||||
@@ -2050,10 +2050,20 @@
|
|||||||
"cancel-in-progress": {
|
"cancel-in-progress": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "To cancel any currently running job or workflow in the same concurrency group, specify cancel-in-progress: true."
|
"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": {
|
"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.",
|
"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": [
|
"context": [
|
||||||
|
|||||||
+28
-1
@@ -25,7 +25,11 @@ jobs:
|
|||||||
concurrency:
|
concurrency:
|
||||||
group: ref
|
group: ref
|
||||||
cancel-in-progress: ${{ github.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"
|
"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