diff --git a/languageservice/src/complete.test.ts b/languageservice/src/complete.test.ts index fcdf659d..acd40a7b 100644 --- a/languageservice/src/complete.test.ts +++ b/languageservice/src/complete.test.ts @@ -305,20 +305,25 @@ jobs: - run: echo - |`; const result = await complete(...getPositionFromCursor(input)); - expect(result).toHaveLength(11); - expect(result.map(x => x.label)).toEqual([ - "continue-on-error", - "env", - "id", - "if", - "name", - "run", - "shell", - "timeout-minutes", - "uses", - "with", - "working-directory" - ]); + expect(result.map(x => x.label)).toEqual( + expect.arrayContaining([ + "background", + "cancel", + "continue-on-error", + "env", + "id", + "if", + "name", + "run", + "shell", + "timeout-minutes", + "uses", + "wait", + "wait-all", + "with", + "working-directory" + ]) + ); // Includes detail when available. Using continue-on-error as a sample here. expect(result.map(x => (x.documentation as MarkupContent)?.value)).toContain( diff --git a/languageservice/src/context-providers/env.ts b/languageservice/src/context-providers/env.ts index f1e3a0d8..b95998d8 100644 --- a/languageservice/src/context-providers/env.ts +++ b/languageservice/src/context-providers/env.ts @@ -7,7 +7,7 @@ export function getEnvContext(workflowContext: WorkflowContext): DescriptionDict const d = new DescriptionDictionary(); //step env - if (workflowContext.step?.env) { + if (workflowContext.step && "env" in workflowContext.step && workflowContext.step.env) { envContext(workflowContext.step.env, d); } diff --git a/languageservice/src/context/workflow-context.ts b/languageservice/src/context/workflow-context.ts index cf9d6c59..ff5c3fa1 100644 --- a/languageservice/src/context/workflow-context.ts +++ b/languageservice/src/context/workflow-context.ts @@ -67,7 +67,10 @@ export function getWorkflowContext( break; } case "regular-step": - case "run-step": { + case "run-step": + case "wait-step": + case "wait-all-step": + case "cancel-step": { if (isMapping(token)) { stepToken = token; } diff --git a/languageservice/src/validate.test.ts b/languageservice/src/validate.test.ts index 82c69e22..6d2f8bf0 100644 --- a/languageservice/src/validate.test.ts +++ b/languageservice/src/validate.test.ts @@ -16,6 +16,49 @@ describe("validation", () => { expect(result.length).toBe(0); }); + it("background step keywords are accepted", async () => { + const result = await validate( + createDocument( + "wf.yaml", + `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - id: server + run: npm start + background: true + - wait: server + continue-on-error: true + - wait-all: + continue-on-error: false + - cancel: server` + ) + ); + + expect(result.length).toBe(0); + }); + + it("wait-all false is rejected", async () => { + const result = await validate( + createDocument( + "wf.yaml", + `on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - wait-all: false` + ) + ); + + expect(result).toContainEqual( + expect.objectContaining({ + message: "The value of 'wait-all' must be true or omitted" + }) + ); + }); + it("missing jobs key", async () => { const result = await validate(createDocument("wf.yaml", "on: push")); diff --git a/workflow-parser/src/model/converter/steps.ts b/workflow-parser/src/model/converter/steps.ts index ba60623a..df8dd9f5 100644 --- a/workflow-parser/src/model/converter/steps.ts +++ b/workflow-parser/src/model/converter/steps.ts @@ -2,6 +2,7 @@ import {TemplateContext} from "../../templates/template-context.js"; import { BasicExpressionToken, MappingToken, + NullToken, ScalarToken, StringToken, TemplateToken @@ -20,10 +21,13 @@ export function convertSteps(context: TemplateContext, steps: TemplateToken): St } const idBuilder = new IdBuilder(); + const knownStepIds = new Set(); const result: Step[] = []; for (const item of steps) { - const step = handleTemplateTokenErrors(steps, context, undefined, () => convertStep(context, idBuilder, item)); + const step = handleTemplateTokenErrors(steps, context, undefined, () => + convertStep(context, idBuilder, knownStepIds, item) + ); if (step) { result.push(step); } @@ -37,6 +41,12 @@ export function convertSteps(context: TemplateContext, steps: TemplateToken): St let id = ""; if (isActionStep(step)) { id = createActionStepId(step); + } else if ("wait" in step) { + id = "wait"; + } else if ("wait-all" in step) { + id = "wait-all"; + } else if ("cancel" in step) { + id = "cancel"; } if (!id) { @@ -50,13 +60,22 @@ export function convertSteps(context: TemplateContext, steps: TemplateToken): St return result; } -function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: TemplateToken): Step | undefined { +function convertStep( + context: TemplateContext, + idBuilder: IdBuilder, + knownStepIds: Set, + step: TemplateToken +): Step | undefined { const mapping = step.assertMapping("steps item"); let run: ScalarToken | undefined; let id: StringToken | undefined; let name: ScalarToken | undefined; let uses: StringToken | undefined; + let background: boolean | undefined; + let wait: StringToken[] | undefined; + let waitAll: boolean | undefined; + let cancel: StringToken | undefined; let continueOnError: boolean | ScalarToken | undefined; let env: MappingToken | undefined; let ifCondition: BasicExpressionToken | undefined; @@ -69,6 +88,8 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ const error = idBuilder.tryAddKnownId(id.value); if (error) { context.error(id, error); + } else { + knownStepIds.add(id.value); } } break; @@ -81,6 +102,19 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ case "uses": uses = item.value.assertString("steps item uses"); break; + case "background": + background = item.value.assertBoolean("steps item background").value; + break; + case "wait": + wait = convertWaitTargets(context, knownStepIds, item.value, id); + break; + case "wait-all": + waitAll = convertWaitAllValue(context, item.value); + break; + case "cancel": + cancel = item.value.assertString("steps item cancel"); + validateTargetStepId(context, knownStepIds, cancel, id); + break; case "env": env = item.value.assertMapping("step env"); break; @@ -103,7 +137,8 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined), "continue-on-error": continueOnError, env, - run + run, + background }; } @@ -114,10 +149,38 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined), "continue-on-error": continueOnError, env, - uses + uses, + background + }; + } + + if (wait) { + return { + id: id?.value || "", + name: name || createSyntheticStepName("Wait"), + "continue-on-error": continueOnError, + wait }; } - context.error(step, "Expected uses or run to be defined"); + + if (waitAll !== undefined) { + return { + id: id?.value || "", + name: name || createSyntheticStepName("Wait for all"), + "continue-on-error": continueOnError, + "wait-all": waitAll + }; + } + + if (cancel) { + return { + id: id?.value || "", + name: name || createSyntheticStepName("Cancel"), + "continue-on-error": continueOnError, + cancel + }; + } + context.error(step, "Expected one of uses, run, wait, wait-all, or cancel to be defined"); } function createActionStepId(step: ActionStep): string { @@ -144,3 +207,57 @@ function createActionStepId(step: ActionStep): string { return ""; } + +function createSyntheticStepName(value: string): ScalarToken { + return new StringToken(undefined, undefined, value, undefined, undefined, undefined); +} + +function convertWaitTargets( + context: TemplateContext, + knownStepIds: Set, + token: TemplateToken, + ownStepId?: StringToken +): StringToken[] { + if (token instanceof StringToken) { + validateTargetStepId(context, knownStepIds, token, ownStepId); + return [token]; + } + + const sequence = token.assertSequence("steps item wait"); + const targets: StringToken[] = []; + for (let i = 0; i < sequence.count; i++) { + const target = sequence.get(i).assertString("steps item wait item"); + validateTargetStepId(context, knownStepIds, target, ownStepId); + targets.push(target); + } + + return targets; +} + +function convertWaitAllValue(context: TemplateContext, token: TemplateToken): boolean { + if (token instanceof NullToken) { + return true; + } + + const value = token.assertBoolean("steps item wait-all").value; + if (!value) { + context.error(token, "The value of 'wait-all' must be true or omitted"); + } + + return true; +} + +function validateTargetStepId( + context: TemplateContext, + knownStepIds: Set, + target: StringToken, + ownStepId?: StringToken +) { + if (target.value.startsWith("__")) { + context.error(target, `The identifier '${target.value}' is invalid. IDs starting with '__' are reserved.`); + } else if (ownStepId && target.value.toLowerCase() === ownStepId.value.toLowerCase()) { + context.error(target, `Step '${ownStepId.value}' cannot reference itself`); + } else if (!knownStepIds.has(target.value)) { + context.error(target, `Step references unknown step ID '${target.value}'`); + } +} diff --git a/workflow-parser/src/model/workflow-template.ts b/workflow-parser/src/model/workflow-template.ts index 39821cec..4494e5ba 100644 --- a/workflow-parser/src/model/workflow-template.ts +++ b/workflow-parser/src/model/workflow-template.ts @@ -84,22 +84,40 @@ export type Credential = { password: StringToken | undefined; }; -export type Step = ActionStep | RunStep; +export type Step = ActionStep | RunStep | WaitStep | WaitAllStep | CancelStep; type BaseStep = { id: string; name?: ScalarToken; - if: BasicExpressionToken; + if?: BasicExpressionToken; "continue-on-error"?: boolean | ScalarToken; +}; + +type ExecutionStep = BaseStep & { + if: BasicExpressionToken; env?: MappingToken; }; -export type RunStep = BaseStep & { +export type RunStep = ExecutionStep & { run: ScalarToken; + background?: boolean; }; -export type ActionStep = BaseStep & { +export type ActionStep = ExecutionStep & { uses: StringToken; + background?: boolean; +}; + +export type WaitStep = BaseStep & { + wait: StringToken[]; +}; + +export type WaitAllStep = BaseStep & { + "wait-all": boolean; +}; + +export type CancelStep = BaseStep & { + cancel: StringToken; }; export type EventsConfig = { diff --git a/workflow-parser/src/workflow-v1.0.json b/workflow-parser/src/workflow-v1.0.json index f514407f..6926745d 100644 --- a/workflow-parser/src/workflow-v1.0.json +++ b/workflow-parser/src/workflow-v1.0.json @@ -2132,7 +2132,7 @@ } }, "steps": { - "description": "A job contains a sequence of tasks called `steps`. Steps can run commands, run setup tasks, or run an action in your repository, a public repository, or an action published in a Docker registry. Not all steps run actions, but all actions run as a step. Each step runs in its own process in the runner environment and has access to the workspace and filesystem. Because steps run in their own process, changes to environment variables are not preserved between steps. GitHub provides built-in steps to set up and complete a job. Must contain either `uses` or `run`.", + "description": "A job contains a sequence of tasks called `steps`. Steps can run commands, run setup tasks, run an action in your repository, a public repository, or an action published in a Docker registry, wait for background steps to complete, or cancel a background step. Not all steps run actions, but all actions run as a step. Each step runs in its own process in the runner environment and has access to the workspace and filesystem. Because steps run in their own process, changes to environment variables are not preserved between steps. GitHub provides built-in steps to set up and complete a job.", "sequence": { "item-type": "steps-item" } @@ -2140,7 +2140,10 @@ "steps-item": { "one-of": [ "run-step", - "regular-step" + "regular-step", + "wait-step", + "wait-all-step", + "cancel-step" ] }, "run-step": { @@ -2158,7 +2161,8 @@ "continue-on-error": "step-continue-on-error", "env": "step-env", "working-directory": "string-steps-context", - "shell": "shell" + "shell": "shell", + "background": "step-background" } } }, @@ -2175,7 +2179,47 @@ "required": true }, "with": "step-with", - "env": "step-env" + "env": "step-env", + "background": "step-background" + } + } + }, + "wait-step": { + "mapping": { + "properties": { + "name": "step-name", + "id": "step-id", + "continue-on-error": "step-continue-on-error", + "wait": { + "type": "step-wait-target", + "required": true + } + } + } + }, + "wait-all-step": { + "mapping": { + "properties": { + "name": "step-name", + "id": "step-id", + "continue-on-error": "step-continue-on-error", + "wait-all": { + "type": "step-wait-all-value", + "required": true + } + } + } + }, + "cancel-step": { + "mapping": { + "properties": { + "name": "step-name", + "id": "step-id", + "continue-on-error": "step-continue-on-error", + "cancel": { + "type": "step-cancel-target", + "required": true + } } } }, @@ -2185,6 +2229,30 @@ "require-non-empty": true } }, + "step-background": { + "boolean": {}, + "description": "When set to true, runs this step in the background. The workflow continues to the next step immediately without waiting for the background step to finish." + }, + "step-wait-target": { + "description": "The step ID or IDs of background steps to wait for.", + "one-of": [ + "non-empty-string", + "sequence-of-non-empty-string" + ] + }, + "step-wait-all-value": { + "description": "Wait for all prior background steps to complete. Use as a bare key (`wait-all:`) or with a boolean value.", + "one-of": [ + "null", + "boolean" + ] + }, + "step-cancel-target": { + "description": "The step ID of a background step to cancel.", + "string": { + "require-non-empty": true + } + }, "step-continue-on-error": { "context": [ "github", diff --git a/workflow-parser/testdata/reader/background-steps.yml b/workflow-parser/testdata/reader/background-steps.yml new file mode 100644 index 00000000..af02fa9b --- /dev/null +++ b/workflow-parser/testdata/reader/background-steps.yml @@ -0,0 +1,71 @@ +include-source: false +--- +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: + - id: server + run: npm start + background: true + - id: checkout + uses: actions/checkout@v4 + background: true + - wait: + - server + - checkout + - wait-all: + - cancel: server +--- +{ + "jobs": [ + { + "type": "job", + "id": "build", + "name": "build", + "if": { + "type": 3, + "expr": "success()" + }, + "runs-on": "ubuntu-latest", + "steps": [ + { + "id": "server", + "if": { + "type": 3, + "expr": "success()" + }, + "run": "npm start", + "background": true + }, + { + "id": "checkout", + "if": { + "type": 3, + "expr": "success()" + }, + "uses": "actions/checkout@v4", + "background": true + }, + { + "id": "__wait", + "name": "Wait", + "wait": [ + "server", + "checkout" + ] + }, + { + "id": "__wait-all", + "name": "Wait for all", + "wait-all": true + }, + { + "id": "__cancel", + "name": "Cancel", + "cancel": "server" + } + ] + } + ] +} diff --git a/workflow-parser/testdata/reader/error.yml b/workflow-parser/testdata/reader/error.yml index 29b93327..f846cfeb 100644 --- a/workflow-parser/testdata/reader/error.yml +++ b/workflow-parser/testdata/reader/error.yml @@ -14,7 +14,7 @@ jobs: "Message": ".github/workflows/error.yml (Line: 7, Col: 9): Unexpected value 'runn'" }, { - "Message": ".github/workflows/error.yml (Line: 7, Col: 9): There's not enough info to determine what you meant. Add one of these properties: run, shell, uses, with, working-directory" + "Message": ".github/workflows/error.yml (Line: 7, Col: 9): There's not enough info to determine what you meant. Add one of these properties: cancel, run, shell, uses, wait, wait-all, with, working-directory" } ] } diff --git a/workflow-parser/testdata/reader/step-continue-on-error.yml b/workflow-parser/testdata/reader/step-continue-on-error.yml index 7a103ede..f54269bf 100644 --- a/workflow-parser/testdata/reader/step-continue-on-error.yml +++ b/workflow-parser/testdata/reader/step-continue-on-error.yml @@ -5,10 +5,17 @@ jobs: build: runs-on: ubuntu-latest steps: - - run: exit 2 + - id: build + run: exit 2 continue-on-error: true - run: exit 1 continue-on-error: false + - wait: build + continue-on-error: true + - wait-all: + continue-on-error: false + - cancel: build + continue-on-error: true - run: exit 1 --- { @@ -24,7 +31,7 @@ jobs: "runs-on": "ubuntu-latest", "steps": [ { - "id": "__run", + "id": "build", "if": { "type": 3, "expr": "success()" @@ -33,7 +40,7 @@ jobs: "run": "exit 2" }, { - "id": "__run_2", + "id": "__run", "if": { "type": 3, "expr": "success()" @@ -42,7 +49,27 @@ jobs: "run": "exit 1" }, { - "id": "__run_3", + "id": "__wait", + "name": "Wait", + "continue-on-error": true, + "wait": [ + "build" + ] + }, + { + "id": "__wait-all", + "name": "Wait for all", + "continue-on-error": false, + "wait-all": true + }, + { + "id": "__cancel", + "name": "Cancel", + "continue-on-error": true, + "cancel": "build" + }, + { + "id": "__run_2", "if": { "type": 3, "expr": "success()"