From ffc3778653cb055ef3cf3ee6f00f9f90e4abec9d Mon Sep 17 00:00:00 2001 From: Jason Ginchereau Date: Wed, 15 Apr 2026 09:36:13 -1000 Subject: [PATCH] Add concurrency queue support (#355) --- expressions/src/features.test.ts | 4 +- expressions/src/features.ts | 9 +- languageserver/README.md | 1 + .../src/validate.concurrency.test.ts | 188 ++++++++++++++++++ languageservice/src/validate.ts | 63 +++++- .../src/model/converter/concurrency.ts | 9 +- .../src/model/workflow-template.ts | 3 + workflow-parser/src/workflow-v1.0.json | 10 + .../testdata/reader/concurrency.yml | 29 ++- 9 files changed, 311 insertions(+), 5 deletions(-) diff --git a/expressions/src/features.test.ts b/expressions/src/features.test.ts index 837b900..bcb138d 100644 --- a/expressions/src/features.test.ts +++ b/expressions/src/features.test.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", () => { @@ -55,7 +56,8 @@ describe("FeatureFlags", () => { "missingInputsQuickfix", "blockScalarChompingWarning", "allowCaseFunction", - "allowCopilotRequestsPermission" + "allowCopilotRequestsPermission", + "allowConcurrencyQueue" ]); }); }); diff --git a/expressions/src/features.ts b/expressions/src/features.ts index d474d2d..1d5bebc 100644 --- a/expressions/src/features.ts +++ b/expressions/src/features.ts @@ -40,6 +40,12 @@ export interface ExperimentalFeatures { * @default false */ allowCopilotRequestsPermission?: boolean; + + /** + * Enable the queue property in workflow concurrency settings. + * @default false + */ + allowConcurrencyQueue?: boolean; } /** @@ -55,7 +61,8 @@ const allFeatureKeys: ExperimentalFeatureKey[] = [ "missingInputsQuickfix", "blockScalarChompingWarning", "allowCaseFunction", - "allowCopilotRequestsPermission" + "allowCopilotRequestsPermission", + "allowConcurrencyQueue" ]; export class FeatureFlags { diff --git a/languageserver/README.md b/languageserver/README.md index c31614b..92b93ff 100644 --- a/languageserver/README.md +++ b/languageserver/README.md @@ -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`. diff --git a/languageservice/src/validate.concurrency.test.ts b/languageservice/src/validate.concurrency.test.ts index dae1a6b..35fec7c 100644 --- a/languageservice/src/validate.concurrency.test.ts +++ b/languageservice/src/validate.concurrency.test.ts @@ -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); + }); + }); +}); diff --git a/languageservice/src/validate.ts b/languageservice/src/validate.ts index a0c0bd8..f6ce8a9 100644 --- a/languageservice/src/validate.ts +++ b/languageservice/src/validate.ts @@ -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. diff --git a/workflow-parser/src/model/converter/concurrency.ts b/workflow-parser/src/model/converter/concurrency.ts index 132bd56..3f4d005 100644 --- a/workflow-parser/src/model/converter/concurrency.ts +++ b/workflow-parser/src/model/converter/concurrency.ts @@ -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}`); } diff --git a/workflow-parser/src/model/workflow-template.ts b/workflow-parser/src/model/workflow-template.ts index 39821ce..373a243 100644 --- a/workflow-parser/src/model/workflow-template.ts +++ b/workflow-parser/src/model/workflow-template.ts @@ -18,9 +18,12 @@ export type WorkflowTemplate = { }[]; }; +export type ConcurrencyQueue = "single" | "max"; + export type ConcurrencySetting = { group?: StringToken; cancelInProgress?: boolean; + queue?: ConcurrencyQueue; }; export type ActionsEnvironmentReference = { diff --git a/workflow-parser/src/workflow-v1.0.json b/workflow-parser/src/workflow-v1.0.json index f514407..8b8968a 100644 --- a/workflow-parser/src/workflow-v1.0.json +++ b/workflow-parser/src/workflow-v1.0.json @@ -2050,10 +2050,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": [ diff --git a/workflow-parser/testdata/reader/concurrency.yml b/workflow-parser/testdata/reader/concurrency.yml index 473f159..148c6c3 100644 --- a/workflow-parser/testdata/reader/concurrency.yml +++ b/workflow-parser/testdata/reader/concurrency.yml @@ -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" } ] } \ No newline at end of file