Skip to content

Commit 053269d

Browse files
committed
Add background step syntax to workflow parser
1 parent cc316ab commit 053269d

10 files changed

Lines changed: 390 additions & 34 deletions

File tree

languageservice/src/complete.test.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -305,20 +305,25 @@ jobs:
305305
- run: echo
306306
- |`;
307307
const result = await complete(...getPositionFromCursor(input));
308-
expect(result).toHaveLength(11);
309-
expect(result.map(x => x.label)).toEqual([
310-
"continue-on-error",
311-
"env",
312-
"id",
313-
"if",
314-
"name",
315-
"run",
316-
"shell",
317-
"timeout-minutes",
318-
"uses",
319-
"with",
320-
"working-directory"
321-
]);
308+
expect(result.map(x => x.label)).toEqual(
309+
expect.arrayContaining([
310+
"background",
311+
"cancel",
312+
"continue-on-error",
313+
"env",
314+
"id",
315+
"if",
316+
"name",
317+
"run",
318+
"shell",
319+
"timeout-minutes",
320+
"uses",
321+
"wait",
322+
"wait-all",
323+
"with",
324+
"working-directory"
325+
])
326+
);
322327

323328
// Includes detail when available. Using continue-on-error as a sample here.
324329
expect(result.map(x => (x.documentation as MarkupContent)?.value)).toContain(

languageservice/src/context-providers/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export function getEnvContext(workflowContext: WorkflowContext): DescriptionDict
77
const d = new DescriptionDictionary();
88

99
//step env
10-
if (workflowContext.step?.env) {
10+
if (workflowContext.step && "env" in workflowContext.step && workflowContext.step.env) {
1111
envContext(workflowContext.step.env, d);
1212
}
1313

languageservice/src/context/workflow-context.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ export function getWorkflowContext(
6767
break;
6868
}
6969
case "regular-step":
70-
case "run-step": {
70+
case "run-step":
71+
case "wait-step":
72+
case "wait-all-step":
73+
case "cancel-step": {
7174
if (isMapping(token)) {
7275
stepToken = token;
7376
}

languageservice/src/validate.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,49 @@ describe("validation", () => {
1616
expect(result.length).toBe(0);
1717
});
1818

19+
it("background step keywords are accepted", async () => {
20+
const result = await validate(
21+
createDocument(
22+
"wf.yaml",
23+
`on: push
24+
jobs:
25+
build:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- id: server
29+
run: npm start
30+
background: true
31+
- wait: server
32+
continue-on-error: true
33+
- wait-all:
34+
continue-on-error: false
35+
- cancel: server`
36+
)
37+
);
38+
39+
expect(result.length).toBe(0);
40+
});
41+
42+
it("wait-all false is rejected", async () => {
43+
const result = await validate(
44+
createDocument(
45+
"wf.yaml",
46+
`on: push
47+
jobs:
48+
build:
49+
runs-on: ubuntu-latest
50+
steps:
51+
- wait-all: false`
52+
)
53+
);
54+
55+
expect(result).toContainEqual(
56+
expect.objectContaining({
57+
message: "The value of 'wait-all' must be true or omitted"
58+
})
59+
);
60+
});
61+
1962
it("missing jobs key", async () => {
2063
const result = await validate(createDocument("wf.yaml", "on: push"));
2164

workflow-parser/src/model/converter/steps.ts

Lines changed: 126 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {TemplateContext} from "../../templates/template-context.js";
22
import {
33
BasicExpressionToken,
44
MappingToken,
5+
NullToken,
56
ScalarToken,
67
StringToken,
78
TemplateToken
@@ -20,11 +21,20 @@ export function convertSteps(context: TemplateContext, steps: TemplateToken): St
2021
}
2122

2223
const idBuilder = new IdBuilder();
24+
const backgroundStepIds = new Set<string>();
2325

2426
const result: Step[] = [];
2527
for (const item of steps) {
26-
const step = handleTemplateTokenErrors(steps, context, undefined, () => convertStep(context, idBuilder, item));
28+
const step = handleTemplateTokenErrors(steps, context, undefined, () =>
29+
convertStep(context, idBuilder, backgroundStepIds, item)
30+
);
2731
if (step) {
32+
if ("background" in step && step.background) {
33+
if (step.id) {
34+
backgroundStepIds.add(step.id.toLowerCase());
35+
}
36+
}
37+
2838
result.push(step);
2939
}
3040
}
@@ -37,6 +47,12 @@ export function convertSteps(context: TemplateContext, steps: TemplateToken): St
3747
let id = "";
3848
if (isActionStep(step)) {
3949
id = createActionStepId(step);
50+
} else if ("wait" in step) {
51+
id = "wait";
52+
} else if ("wait-all" in step) {
53+
id = "wait-all";
54+
} else if ("cancel" in step) {
55+
id = "cancel";
4056
}
4157

4258
if (!id) {
@@ -50,13 +66,22 @@ export function convertSteps(context: TemplateContext, steps: TemplateToken): St
5066
return result;
5167
}
5268

53-
function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: TemplateToken): Step | undefined {
69+
function convertStep(
70+
context: TemplateContext,
71+
idBuilder: IdBuilder,
72+
backgroundStepIds: Set<string>,
73+
step: TemplateToken
74+
): Step | undefined {
5475
const mapping = step.assertMapping("steps item");
5576

5677
let run: ScalarToken | undefined;
5778
let id: StringToken | undefined;
5879
let name: ScalarToken | undefined;
5980
let uses: StringToken | undefined;
81+
let background: boolean | undefined;
82+
let wait: StringToken[] | undefined;
83+
let waitAll: boolean | undefined;
84+
let cancel: StringToken | undefined;
6085
let continueOnError: boolean | ScalarToken | undefined;
6186
let env: MappingToken | undefined;
6287
let ifCondition: BasicExpressionToken | undefined;
@@ -81,6 +106,19 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
81106
case "uses":
82107
uses = item.value.assertString("steps item uses");
83108
break;
109+
case "background":
110+
background = item.value.assertBoolean("steps item background").value;
111+
break;
112+
case "wait":
113+
wait = convertWaitTargets(context, backgroundStepIds, item.value, id);
114+
break;
115+
case "wait-all":
116+
waitAll = convertWaitAllValue(context, item.value);
117+
break;
118+
case "cancel":
119+
cancel = item.value.assertString("steps item cancel");
120+
validateTargetStepId(context, backgroundStepIds, cancel, id);
121+
break;
84122
case "env":
85123
env = item.value.assertMapping("step env");
86124
break;
@@ -103,7 +141,8 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
103141
if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
104142
"continue-on-error": continueOnError,
105143
env,
106-
run
144+
run,
145+
background
107146
};
108147
}
109148

@@ -114,10 +153,38 @@ function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: Templ
114153
if: ifCondition || new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
115154
"continue-on-error": continueOnError,
116155
env,
117-
uses
156+
uses,
157+
background
118158
};
119159
}
120-
context.error(step, "Expected uses or run to be defined");
160+
161+
if (wait) {
162+
return {
163+
id: id?.value || "",
164+
name: name || createSyntheticStepName("Wait"),
165+
"continue-on-error": continueOnError,
166+
wait
167+
};
168+
}
169+
170+
if (waitAll !== undefined) {
171+
return {
172+
id: id?.value || "",
173+
name: name || createSyntheticStepName("Wait for all"),
174+
"continue-on-error": continueOnError,
175+
"wait-all": waitAll
176+
};
177+
}
178+
179+
if (cancel) {
180+
return {
181+
id: id?.value || "",
182+
name: name || createSyntheticStepName("Cancel"),
183+
"continue-on-error": continueOnError,
184+
cancel
185+
};
186+
}
187+
context.error(step, "Expected one of uses, run, wait, wait-all, or cancel to be defined");
121188
}
122189

123190
function createActionStepId(step: ActionStep): string {
@@ -144,3 +211,57 @@ function createActionStepId(step: ActionStep): string {
144211

145212
return "";
146213
}
214+
215+
function createSyntheticStepName(value: string): ScalarToken {
216+
return new StringToken(undefined, undefined, value, undefined, undefined, undefined);
217+
}
218+
219+
function convertWaitTargets(
220+
context: TemplateContext,
221+
backgroundStepIds: Set<string>,
222+
token: TemplateToken,
223+
ownStepId?: StringToken
224+
): StringToken[] {
225+
if (token instanceof StringToken) {
226+
validateTargetStepId(context, backgroundStepIds, token, ownStepId);
227+
return [token];
228+
}
229+
230+
const sequence = token.assertSequence("steps item wait");
231+
const targets: StringToken[] = [];
232+
for (let i = 0; i < sequence.count; i++) {
233+
const target = sequence.get(i).assertString("steps item wait item");
234+
validateTargetStepId(context, backgroundStepIds, target, ownStepId);
235+
targets.push(target);
236+
}
237+
238+
return targets;
239+
}
240+
241+
function convertWaitAllValue(context: TemplateContext, token: TemplateToken): boolean {
242+
if (token instanceof NullToken) {
243+
return true;
244+
}
245+
246+
const value = token.assertBoolean("steps item wait-all").value;
247+
if (!value) {
248+
context.error(token, "The value of 'wait-all' must be true or omitted");
249+
}
250+
251+
return true;
252+
}
253+
254+
function validateTargetStepId(
255+
context: TemplateContext,
256+
backgroundStepIds: Set<string>,
257+
target: StringToken,
258+
ownStepId?: StringToken
259+
) {
260+
if (target.value.startsWith("__")) {
261+
context.error(target, `The identifier '${target.value}' is invalid. IDs starting with '__' are reserved.`);
262+
} else if (ownStepId && target.value.toLowerCase() === ownStepId.value.toLowerCase()) {
263+
context.error(target, `Step '${ownStepId.value}' cannot reference itself`);
264+
} else if (!backgroundStepIds.has(target.value.toLowerCase())) {
265+
context.error(target, `Step references unknown background step ID '${target.value}'`);
266+
}
267+
}

workflow-parser/src/model/workflow-template.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,22 +84,40 @@ export type Credential = {
8484
password: StringToken | undefined;
8585
};
8686

87-
export type Step = ActionStep | RunStep;
87+
export type Step = ActionStep | RunStep | WaitStep | WaitAllStep | CancelStep;
8888

8989
type BaseStep = {
9090
id: string;
9191
name?: ScalarToken;
92-
if: BasicExpressionToken;
92+
if?: BasicExpressionToken;
9393
"continue-on-error"?: boolean | ScalarToken;
94+
};
95+
96+
type ExecutionStep = BaseStep & {
97+
if: BasicExpressionToken;
9498
env?: MappingToken;
9599
};
96100

97-
export type RunStep = BaseStep & {
101+
export type RunStep = ExecutionStep & {
98102
run: ScalarToken;
103+
background?: boolean;
99104
};
100105

101-
export type ActionStep = BaseStep & {
106+
export type ActionStep = ExecutionStep & {
102107
uses: StringToken;
108+
background?: boolean;
109+
};
110+
111+
export type WaitStep = BaseStep & {
112+
wait: StringToken[];
113+
};
114+
115+
export type WaitAllStep = BaseStep & {
116+
"wait-all": boolean;
117+
};
118+
119+
export type CancelStep = BaseStep & {
120+
cancel: StringToken;
103121
};
104122

105123
export type EventsConfig = {

0 commit comments

Comments
 (0)