Add concurrency queue support (#355)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user