Skip to content

Commit dec597b

Browse files
authored
Improve cron schedule validation and diagnostics (#224)
1 parent bd7e5f0 commit dec597b

File tree

7 files changed

+266
-41
lines changed

7 files changed

+266
-41
lines changed

languageservice/src/hover.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,8 @@ jobs:
110110
`;
111111
const result = await hover(...getPositionFromCursor(input));
112112
expect(result).not.toBeUndefined();
113-
expect(result?.contents).toEqual(
114-
"Runs at 0 and 30 minutes past the hour, at 00:00 and 12:00\n\n" +
115-
"Actions schedules run at most every 5 minutes. " +
116-
"[Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
117-
);
113+
// Cron description is now shown via diagnostics, not hover
114+
expect(result?.contents).toEqual("");
118115
});
119116

120117
it("on a cron mapping key", async () => {

languageservice/src/hover.ts

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ import {data, DescriptionDictionary, Parser} from "@actions/expressions";
22
import {FunctionDefinition, FunctionInfo} from "@actions/expressions/funcs/info";
33
import {Lexer} from "@actions/expressions/lexer";
44
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
5-
import {getCronDescription} from "@actions/workflow-parser/model/converter/cron";
65
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
7-
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
86
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
9-
import {isBasicExpression, isString} from "@actions/workflow-parser/templates/tokens/type-guards";
7+
import {isBasicExpression} from "@actions/workflow-parser/templates/tokens/type-guards";
108
import {File} from "@actions/workflow-parser/workflows/file";
119
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
1210
import {Position, TextDocument} from "vscode-languageserver-textdocument";
@@ -23,7 +21,7 @@ import {ExpressionPos, mapToExpressionPos} from "./expression-hover/expression-p
2321
import {HoverVisitor} from "./expression-hover/visitor";
2422
import {info} from "./log";
2523
import {isPotentiallyExpression} from "./utils/expression-detection";
26-
import {findToken, TokenResult} from "./utils/find-token";
24+
import {findToken} from "./utils/find-token";
2725
import {mapRange} from "./utils/range";
2826
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
2927

@@ -89,17 +87,6 @@ export async function hover(document: TextDocument, position: Position, config?:
8987

9088
info(`Calculating hover for token with definition ${token.definition.key}`);
9189

92-
if (tokenResult.parent && isCronMappingValue(tokenResult)) {
93-
const tokenValue = (token as StringToken).value;
94-
const description = getCronDescription(tokenValue);
95-
if (description) {
96-
return {
97-
contents: description,
98-
range: mapRange(token.range)
99-
} satisfies Hover;
100-
}
101-
}
102-
10390
if (tokenResult.parent && isReusableWorkflowJobInput(tokenResult)) {
10491
let description = getReusableWorkflowInputDescription(workflowContext, tokenResult);
10592
description = appendContext(description, token.definitionInfo?.allowedContext);
@@ -156,15 +143,6 @@ async function getDescription(
156143
return description || defaultDescription;
157144
}
158145

159-
function isCronMappingValue(tokenResult: TokenResult): boolean {
160-
return (
161-
tokenResult.parent?.definition?.key === "cron-mapping" &&
162-
!!tokenResult.token &&
163-
isString(tokenResult.token) &&
164-
tokenResult.token.value !== "cron"
165-
);
166-
}
167-
168146
function expressionHover(
169147
exprPos: ExpressionPos,
170148
context: DescriptionDictionary,

languageservice/src/validate.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ jobs:
181181

182182
expect(result.length).toBe(1);
183183
expect(result[0]).toEqual({
184-
message: "Invalid cron string",
184+
message: "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)",
185185
range: {
186186
end: {
187187
character: 21,
@@ -195,6 +195,95 @@ jobs:
195195
} as Diagnostic);
196196
});
197197

198+
it("cron with interval less than 5 minutes shows warning", async () => {
199+
const result = await validate(
200+
createDocument(
201+
"wf.yaml",
202+
`on:
203+
schedule:
204+
- cron: '*/1 * * * *'
205+
jobs:
206+
build:
207+
runs-on: ubuntu-latest`
208+
),
209+
{valueProviderConfig: defaultValueProviders}
210+
);
211+
212+
expect(result.length).toBe(1);
213+
expect(result[0]).toEqual({
214+
message: "Runs every minute. Note: Actions schedules run at most every 5 minutes.",
215+
severity: DiagnosticSeverity.Warning,
216+
code: "on-schedule",
217+
codeDescription: {
218+
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
219+
},
220+
range: {
221+
end: {
222+
character: 25,
223+
line: 2
224+
},
225+
start: {
226+
character: 12,
227+
line: 2
228+
}
229+
}
230+
} as Diagnostic);
231+
});
232+
233+
it("cron with interval of 5 minutes or more shows info", async () => {
234+
const result = await validate(
235+
createDocument(
236+
"wf.yaml",
237+
`on:
238+
schedule:
239+
- cron: '*/5 * * * *'
240+
jobs:
241+
build:
242+
runs-on: ubuntu-latest`
243+
),
244+
{valueProviderConfig: defaultValueProviders}
245+
);
246+
247+
expect(result.length).toBe(1);
248+
expect(result[0]).toEqual({
249+
message: "Runs every 5 minutes",
250+
severity: DiagnosticSeverity.Information,
251+
code: "on-schedule",
252+
codeDescription: {
253+
href: "https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule"
254+
},
255+
range: {
256+
end: {
257+
character: 25,
258+
line: 2
259+
},
260+
start: {
261+
character: 12,
262+
line: 2
263+
}
264+
}
265+
} as Diagnostic);
266+
});
267+
268+
it("cron with comma-separated minutes less than 5 apart shows warning", async () => {
269+
const result = await validate(
270+
createDocument(
271+
"wf.yaml",
272+
`on:
273+
schedule:
274+
- cron: '0,2 * * * *'
275+
jobs:
276+
build:
277+
runs-on: ubuntu-latest`
278+
),
279+
{valueProviderConfig: defaultValueProviders}
280+
);
281+
282+
expect(result.length).toBe(1);
283+
expect(result[0]?.severity).toBe(DiagnosticSeverity.Warning);
284+
expect(result[0]?.message).toContain("Note: Actions schedules run at most every 5 minutes.");
285+
});
286+
198287
it("invalid YAML", async () => {
199288
// This YAML has some mismatched single-quotes, which causes the string to be terminated early
200289
// within the fromJSON() expression.

languageservice/src/validate.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {Lexer, Parser, data} from "@actions/expressions";
22
import {Expr, FunctionCall, Literal, Logical} from "@actions/expressions/ast";
33
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
44
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
5+
import {getCronDescription, hasCronIntervalLessThan5Minutes} from "@actions/workflow-parser/model/converter/cron";
56
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
67
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
78
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
@@ -27,6 +28,9 @@ import {validateAction} from "./validate-action";
2728
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
2829
import {defaultValueProviders} from "./value-providers/default";
2930

31+
const CRON_SCHEDULE_DOCS_URL =
32+
"https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule";
33+
3034
export type ValidationConfig = {
3135
valueProviderConfig?: ValueProviderConfig;
3236
contextProviderConfig?: ContextProviderConfig;
@@ -166,6 +170,11 @@ async function additionalValidations(
166170
validateWorkflowUsesFormat(diagnostics, token);
167171
}
168172

173+
// Validate cron expressions - warn if interval is less than 5 minutes
174+
if (isString(token) && token.range && validationDefinition?.key === "cron-pattern") {
175+
validateCronExpression(diagnostics, token);
176+
}
177+
169178
// Allowed values coming from the schema have already been validated. Only check if
170179
// a value provider is defined for a token and if it is, validate the values match.
171180
if (token.range && validationDefinition) {
@@ -216,6 +225,50 @@ function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: Value
216225
}
217226
}
218227

228+
/**
229+
* Validates cron expressions and provides diagnostics for valid cron schedules.
230+
* Shows a warning if the interval is less than 5 minutes (since GitHub Actions
231+
* schedules run at most every 5 minutes), otherwise shows an info message.
232+
*/
233+
function validateCronExpression(diagnostics: Diagnostic[], token: StringToken): void {
234+
const cronValue = token.value;
235+
236+
// Ensure we have a range for diagnostics
237+
if (!token.range) {
238+
return;
239+
}
240+
241+
// Only check valid cron expressions - invalid ones are already caught by the parser
242+
const description = getCronDescription(cronValue);
243+
if (!description) {
244+
return;
245+
}
246+
247+
// Check if the cron specifies an interval less than 5 minutes
248+
if (hasCronIntervalLessThan5Minutes(cronValue)) {
249+
diagnostics.push({
250+
message: `${description}. Note: Actions schedules run at most every 5 minutes.`,
251+
range: mapRange(token.range),
252+
severity: DiagnosticSeverity.Warning,
253+
code: "on-schedule",
254+
codeDescription: {
255+
href: CRON_SCHEDULE_DOCS_URL
256+
}
257+
});
258+
} else {
259+
// Show info message for valid cron expressions
260+
diagnostics.push({
261+
message: description,
262+
range: mapRange(token.range),
263+
severity: DiagnosticSeverity.Information,
264+
code: "on-schedule",
265+
codeDescription: {
266+
href: CRON_SCHEDULE_DOCS_URL
267+
}
268+
});
269+
}
270+
}
271+
219272
/**
220273
* Validates the format of a step's `uses` field.
221274
*

workflow-parser/src/model/converter/cron.test.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {isValidCron, getCronDescription} from "./cron";
1+
import {isValidCron, getCronDescription, hasCronIntervalLessThan5Minutes} from "./cron";
22

33
describe("cron", () => {
44
describe("valid cron", () => {
@@ -66,14 +66,54 @@ describe("cron", () => {
6666

6767
describe("getCronDescription", () => {
6868
it(`Produces a sentence for valid cron`, () => {
69-
expect(getCronDescription("0 * * * *")).toEqual(
70-
"Runs every hour\n\n" +
71-
"Actions schedules run at most every 5 minutes. [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
72-
);
69+
expect(getCronDescription("0 * * * *")).toEqual("Runs every hour");
7370
});
7471

7572
it(`Returns nothing for invalid cron`, () => {
7673
expect(getCronDescription("* * * * * *")).toBeUndefined();
7774
});
7875
});
76+
77+
describe("hasCronIntervalLessThan5Minutes", () => {
78+
it("returns true for step expressions with interval < 5 min", () => {
79+
expect(hasCronIntervalLessThan5Minutes("*/1 * * * *")).toBe(true);
80+
expect(hasCronIntervalLessThan5Minutes("*/4 * * * *")).toBe(true);
81+
});
82+
83+
it("returns false for step expressions with interval >= 5 min", () => {
84+
expect(hasCronIntervalLessThan5Minutes("*/5 * * * *")).toBe(false);
85+
expect(hasCronIntervalLessThan5Minutes("*/15 * * * *")).toBe(false);
86+
});
87+
88+
it("returns true for comma-separated values with gap < 5 min", () => {
89+
expect(hasCronIntervalLessThan5Minutes("0,2,4 * * * *")).toBe(true);
90+
expect(hasCronIntervalLessThan5Minutes("0,10,12 * * * *")).toBe(true);
91+
});
92+
93+
it("returns false for comma-separated values with gap >= 5 min", () => {
94+
expect(hasCronIntervalLessThan5Minutes("0,10,20 * * * *")).toBe(false);
95+
expect(hasCronIntervalLessThan5Minutes("0,30 * * * *")).toBe(false);
96+
});
97+
98+
it("returns true for comma-separated values with wrap-around gap < 5 min", () => {
99+
expect(hasCronIntervalLessThan5Minutes("0,58 * * * *")).toBe(true);
100+
expect(hasCronIntervalLessThan5Minutes("2,59 * * * *")).toBe(true);
101+
});
102+
103+
it("returns true for * (every minute)", () => {
104+
expect(hasCronIntervalLessThan5Minutes("* * * * *")).toBe(true);
105+
});
106+
107+
it("returns true for range expressions (runs every minute in range)", () => {
108+
expect(hasCronIntervalLessThan5Minutes("0-4 * * * *")).toBe(true);
109+
});
110+
111+
it("returns false for single value (hourly)", () => {
112+
expect(hasCronIntervalLessThan5Minutes("0 * * * *")).toBe(false);
113+
});
114+
115+
it("returns false for invalid cron", () => {
116+
expect(hasCronIntervalLessThan5Minutes("invalid")).toBe(false);
117+
});
118+
});
79119
});

0 commit comments

Comments
 (0)