Skip to content

Commit a7f581b

Browse files
Add timezone to workflow and pass FF (#334)
* Add timezone to workflow and pass FF * Prettier fixes * Prettier fixes * Prettier fixes * Guard timezone autocomplete behind FF * Prettier fix * Address PR comments * Prettier fix * Remove comma * Remove template assignment * Move description * Fix test * Prettier again! * Address comments * Change error when timezone key is entered but FF is off * Prettier --------- Co-authored-by: Angel Kou <jiakou@microsoft.com>
1 parent 8c0a3a9 commit a7f581b

File tree

11 files changed

+267
-21
lines changed

11 files changed

+267
-21
lines changed

expressions/src/features.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ describe("FeatureFlags", () => {
5454
expect(flags.getEnabledFeatures()).toEqual([
5555
"missingInputsQuickfix",
5656
"blockScalarChompingWarning",
57-
"allowCaseFunction"
57+
"allowCaseFunction",
58+
"allowCronTimezone"
5859
]);
5960
});
6061
});

expressions/src/features.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export interface ExperimentalFeatures {
3434
* @default false
3535
*/
3636
allowCaseFunction?: boolean;
37+
38+
/**
39+
* Enable the timezone input in cron schedule mappings.
40+
* @default false
41+
*/
42+
allowCronTimezone?: boolean;
3743
}
3844

3945
/**
@@ -48,7 +54,8 @@ export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
4854
const allFeatureKeys: ExperimentalFeatureKey[] = [
4955
"missingInputsQuickfix",
5056
"blockScalarChompingWarning",
51-
"allowCaseFunction"
57+
"allowCaseFunction",
58+
"allowCronTimezone"
5259
];
5360

5461
export class FeatureFlags {

languageservice/src/complete.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,3 +925,45 @@ jobs:
925925
});
926926
});
927927
});
928+
929+
describe("schedule timezone completion", () => {
930+
it("includes timezone when allowCronTimezone is enabled", async () => {
931+
const input = `on:
932+
schedule:
933+
- |`;
934+
const result = await complete(...getPositionFromCursor(input), {
935+
featureFlags: new FeatureFlags({allowCronTimezone: true})
936+
});
937+
938+
expect(result).not.toBeUndefined();
939+
const labels = result.map(x => x.label);
940+
expect(labels).toContain("cron");
941+
expect(labels).toContain("timezone");
942+
});
943+
944+
it("excludes timezone when allowCronTimezone is disabled", async () => {
945+
const input = `on:
946+
schedule:
947+
- |`;
948+
const result = await complete(...getPositionFromCursor(input), {
949+
featureFlags: new FeatureFlags({allowCronTimezone: false})
950+
});
951+
952+
expect(result).not.toBeUndefined();
953+
const labels = result.map(x => x.label);
954+
expect(labels).toContain("cron");
955+
expect(labels).not.toContain("timezone");
956+
});
957+
958+
it("excludes timezone when no feature flags are provided", async () => {
959+
const input = `on:
960+
schedule:
961+
- |`;
962+
const result = await complete(...getPositionFromCursor(input));
963+
964+
expect(result).not.toBeUndefined();
965+
const labels = result.map(x => x.label);
966+
expect(labels).toContain("cron");
967+
expect(labels).not.toContain("timezone");
968+
});
969+
});

languageservice/src/complete.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,11 @@ export async function complete(
163163
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
164164
}
165165

166+
// Filter `timezone` from schedule completions when the feature flag is disabled
167+
if (!config?.featureFlags?.isEnabled("allowCronTimezone") && parent?.definition?.key === "schedule") {
168+
values = values.filter(v => v.label !== "timezone");
169+
}
170+
166171
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
167172
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
168173
values.push(...escapeHatches);

languageservice/src/hover.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ jobs:
120120
`;
121121
const result = await hover(...getPositionFromCursor(input));
122122
expect(result).not.toBeUndefined();
123-
expect(result?.contents).toEqual("");
123+
expect(result?.contents).toEqual(
124+
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
125+
);
124126
});
125127

126128
it("on an invalid cron schedule", async () => {
@@ -130,7 +132,9 @@ jobs:
130132
`;
131133
const result = await hover(...getPositionFromCursor(input));
132134
expect(result).not.toBeUndefined();
133-
expect(result?.contents).toEqual("");
135+
expect(result?.contents).toEqual(
136+
"A cron expression that represents a schedule. A scheduled workflow will run at most once every 5 minutes."
137+
);
134138
});
135139

136140
it("shows context inherited from parent nodes", async () => {

languageservice/src/validate.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ async function validateWorkflow(textDocument: TextDocument, config?: ValidationC
8484
// Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information
8585
const template = await getOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
8686
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
87-
errorPolicy: ErrorPolicy.TryConversion
87+
errorPolicy: ErrorPolicy.TryConversion,
88+
featureFlags: config?.featureFlags
8889
});
8990

9091
// Validate expressions and value providers

workflow-parser/src/model/convert.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2+
import {FeatureFlags} from "@actions/expressions/features";
23
import {nullTrace} from "../test-utils/null-trace.js";
34
import {parseWorkflow} from "../workflows/workflow-parser.js";
45
import {convertWorkflowTemplate, ErrorPolicy} from "./convert.js";
@@ -578,4 +579,140 @@ jobs:
578579
}
579580
});
580581
});
582+
583+
describe("schedule timezone with feature flags", () => {
584+
it("allows timezone when allowCronTimezone is enabled", async () => {
585+
const result = parseWorkflow(
586+
{
587+
name: "wf.yaml",
588+
content: `on:
589+
schedule:
590+
- cron: '0 0 * * *'
591+
timezone: America/New_York
592+
jobs:
593+
build:
594+
runs-on: ubuntu-latest`
595+
},
596+
nullTrace
597+
);
598+
599+
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
600+
errorPolicy: ErrorPolicy.TryConversion,
601+
featureFlags: new FeatureFlags({allowCronTimezone: true})
602+
});
603+
604+
expect(result.context.errors.getErrors()).toHaveLength(0);
605+
expect(template.events?.schedule).toHaveLength(1);
606+
expect(template.events?.schedule?.[0]).toEqual({
607+
cron: "0 0 * * *",
608+
timezone: "America/New_York"
609+
});
610+
});
611+
612+
it("reports error when timezone is present but allowCronTimezone is disabled", async () => {
613+
const result = parseWorkflow(
614+
{
615+
name: "wf.yaml",
616+
content: `on:
617+
schedule:
618+
- cron: '0 0 * * *'
619+
timezone: America/New_York
620+
jobs:
621+
build:
622+
runs-on: ubuntu-latest`
623+
},
624+
nullTrace
625+
);
626+
627+
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
628+
errorPolicy: ErrorPolicy.TryConversion,
629+
featureFlags: new FeatureFlags({allowCronTimezone: false})
630+
});
631+
632+
// When timezone feature is disabled, error points at the timezone key
633+
expect(result.context.errors.getErrors()).toHaveLength(1);
634+
expect(result.context.errors.getErrors()[0].message).toContain("Key 'timezone' is not supported");
635+
// Schedule entry is dropped due to unsupported key
636+
expect(template.events?.schedule).toHaveLength(0);
637+
});
638+
639+
it("reports error when timezone is present with no feature flags provided", async () => {
640+
const result = parseWorkflow(
641+
{
642+
name: "wf.yaml",
643+
content: `on:
644+
schedule:
645+
- cron: '0 0 * * *'
646+
timezone: America/New_York
647+
jobs:
648+
build:
649+
runs-on: ubuntu-latest`
650+
},
651+
nullTrace
652+
);
653+
654+
await convertWorkflowTemplate(result.context, result.value!, undefined, {
655+
errorPolicy: ErrorPolicy.TryConversion
656+
});
657+
658+
// Default is timezone disabled, so error points at the timezone key
659+
expect(result.context.errors.getErrors()).toHaveLength(1);
660+
expect(result.context.errors.getErrors()[0].message).toContain("Key 'timezone' is not supported");
661+
});
662+
663+
it("reports error when cron is missing from schedule entry", async () => {
664+
const result = parseWorkflow(
665+
{
666+
name: "wf.yaml",
667+
content: `on:
668+
schedule:
669+
- timezone: America/New_York
670+
jobs:
671+
build:
672+
runs-on: ubuntu-latest`
673+
},
674+
nullTrace
675+
);
676+
677+
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
678+
errorPolicy: ErrorPolicy.TryConversion,
679+
featureFlags: new FeatureFlags({allowCronTimezone: true})
680+
});
681+
682+
// Both schema validation and converter report the missing cron
683+
expect(result.context.errors.getErrors().length).toBeGreaterThanOrEqual(1);
684+
const errorMessages = result.context.errors
685+
.getErrors()
686+
.map(e => e.message)
687+
.join(", ");
688+
expect(errorMessages).toMatch(/Required property is missing: cron|Missing required key 'cron'/);
689+
expect(template.events?.schedule).toHaveLength(0);
690+
});
691+
692+
it("converts schedule without timezone when allowCronTimezone is enabled", async () => {
693+
const result = parseWorkflow(
694+
{
695+
name: "wf.yaml",
696+
content: `on:
697+
schedule:
698+
- cron: '0 0 * * *'
699+
jobs:
700+
build:
701+
runs-on: ubuntu-latest`
702+
},
703+
nullTrace
704+
);
705+
706+
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
707+
errorPolicy: ErrorPolicy.TryConversion,
708+
featureFlags: new FeatureFlags({allowCronTimezone: true})
709+
});
710+
711+
expect(result.context.errors.getErrors()).toHaveLength(0);
712+
expect(template.events?.schedule).toHaveLength(1);
713+
expect(template.events?.schedule?.[0]).toEqual({
714+
cron: "0 0 * * *"
715+
});
716+
});
717+
});
581718
});

workflow-parser/src/model/convert.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {FeatureFlags} from "@actions/expressions/features";
12
import {TemplateContext} from "../templates/template-context.js";
23
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token.js";
34
import {FileProvider} from "../workflows/file-provider.js";
@@ -37,12 +38,18 @@ export type WorkflowTemplateConverterOptions = {
3738
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
3839
*/
3940
errorPolicy?: ErrorPolicy;
41+
42+
/**
43+
* Optional feature flags to control which experimental features are enabled.
44+
*/
45+
featureFlags?: FeatureFlags;
4046
};
4147

4248
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
4349
maxReusableWorkflowDepth: 4,
4450
fetchReusableWorkflowDepth: 0,
45-
errorPolicy: ErrorPolicy.ReturnErrorsOnly
51+
errorPolicy: ErrorPolicy.ReturnErrorsOnly,
52+
featureFlags: new FeatureFlags()
4653
};
4754

4855
export async function convertWorkflowTemplate(
@@ -54,6 +61,11 @@ export async function convertWorkflowTemplate(
5461
const result = {} as WorkflowTemplate;
5562
const opts = getOptionsWithDefaults(options);
5663

64+
// Store feature flags in context state so converters can access them
65+
if (opts.featureFlags) {
66+
context.state["featureFlags"] = opts.featureFlags;
67+
}
68+
5769
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
5870
result.errors = context.errors.getErrors().map(x => ({
5971
Message: x.message
@@ -142,6 +154,7 @@ function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Requ
142154
options.fetchReusableWorkflowDepth !== undefined
143155
? options.fetchReusableWorkflowDepth
144156
: defaultOptions.fetchReusableWorkflowDepth,
145-
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy
157+
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy,
158+
featureFlags: options.featureFlags ?? defaultOptions.featureFlags
146159
};
147160
}

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

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {FeatureFlags} from "@actions/expressions/features";
12
import {TemplateContext} from "../../templates/template-context.js";
23
import {MappingToken} from "../../templates/tokens/mapping-token.js";
34
import {SequenceToken} from "../../templates/tokens/sequence-token.js";
@@ -55,7 +56,8 @@ export function convertOn(context: TemplateContext, token: TemplateToken): Event
5556
// Schedule is the only event that can be a sequence, handle that separately
5657
if (eventName === "schedule") {
5758
const scheduleToken = item.value.assertSequence(`event ${eventName}`);
58-
result.schedule = convertSchedule(context, scheduleToken);
59+
const featureFlags = context.state["featureFlags"] as FeatureFlags | undefined;
60+
result.schedule = convertSchedule(context, scheduleToken, featureFlags);
5961
continue;
6062
}
6163

@@ -147,25 +149,47 @@ function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig & Vers
147149
return result;
148150
}
149151

150-
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
152+
function convertSchedule(
153+
context: TemplateContext,
154+
token: SequenceToken,
155+
featureFlags?: FeatureFlags
156+
): ScheduleConfig[] | undefined {
157+
const flags = featureFlags ?? new FeatureFlags();
158+
const allowTimezone = flags.isEnabled("allowCronTimezone");
151159
const result = [] as ScheduleConfig[];
160+
152161
for (const item of token) {
153162
const mappingToken = item.assertMapping(`event schedule`);
154-
if (mappingToken.count == 1) {
155-
const schedule = mappingToken.get(0);
156-
const scheduleKey = schedule.key.assertString(`schedule key`);
157-
if (scheduleKey.value == "cron") {
158-
const cron = schedule.value.assertString(`schedule cron`);
159-
// Validate the cron string
163+
const config: ScheduleConfig = {cron: ""};
164+
let valid = true;
165+
166+
for (const entry of mappingToken) {
167+
const key = entry.key.assertString(`schedule key`);
168+
169+
if (key.value === "cron") {
170+
const cron = entry.value.assertString(`schedule cron`);
160171
if (!isValidCron(cron.value)) {
161172
context.error(cron, "Invalid cron expression. Expected format: '* * * * *' (minute hour day month weekday)");
162173
}
163-
result.push({cron: cron.value});
174+
config.cron = cron.value;
175+
} else if (key.value === "timezone") {
176+
if (allowTimezone) {
177+
const timezone = entry.value.assertString(`schedule timezone`);
178+
config.timezone = timezone.value;
179+
} else {
180+
context.error(key, `Key 'timezone' is not supported`);
181+
valid = false;
182+
}
164183
} else {
165-
context.error(scheduleKey, `Invalid schedule key`);
184+
context.error(key, `Invalid schedule key`);
185+
valid = false;
166186
}
167-
} else {
168-
context.error(mappingToken, "Invalid format for 'schedule'");
187+
}
188+
189+
if (valid && config.cron) {
190+
result.push(config);
191+
} else if (valid && !config.cron) {
192+
context.error(mappingToken, "Missing required key 'cron' in schedule entry");
169193
}
170194
}
171195

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export type SecretConfig = {
196196

197197
export type ScheduleConfig = {
198198
cron: string;
199+
timezone?: string;
199200
};
200201

201202
export type WorkflowFilterConfig = {

0 commit comments

Comments
 (0)