Skip to content

Commit 88a72b0

Browse files
authored
Add typed on.stop-after field; allow GitHub Actions expressions (#56983)
1 parent e48b8e3 commit 88a72b0

10 files changed

Lines changed: 473 additions & 67 deletions

File tree

.changeset/type-on-stop-after.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

actions/setup/js/check_stop_time.cjs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,66 @@
33

44
const { ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs");
55
const { writeDenialSummary } = require("./pre_activation_summary.cjs");
6+
7+
// Matches a relative time delta such as "+25h", "+3d", "+1w", "+1mo", "+1d12h".
8+
// Mirrors pkg/workflow/time_delta.go's parseTimeDeltaForStopAfter: minutes are not
9+
// supported since the minimum unit for stop-after is hours.
10+
const TIME_DELTA_PATTERN = /(\d+)(mo|w|d|h)/g;
11+
12+
/** @param {string} stopTime */
13+
function isRelativeStopTime(stopTime) {
14+
return stopTime.startsWith("+");
15+
}
16+
17+
/** @param {string} deltaStr */
18+
function parseTimeDeltaForStopAfter(deltaStr) {
19+
const rest = deltaStr.slice(1);
20+
if (!rest) {
21+
throw new Error("empty time delta after '+'");
22+
}
23+
24+
const matches = [...rest.matchAll(TIME_DELTA_PATTERN)];
25+
if (matches.length === 0) {
26+
throw new Error(`invalid time delta format: +${rest}. Expected format like +25h, +3d, +1w, +1mo, +1d12h`);
27+
}
28+
29+
const consumed = matches.reduce((sum, match) => sum + match[0].length, 0);
30+
if (consumed !== rest.length) {
31+
throw new Error(`invalid time delta format: +${rest}. Extra characters detected`);
32+
}
33+
34+
const delta = { months: 0, weeks: 0, days: 0, hours: 0 };
35+
const seenUnits = new Set();
36+
for (const [, valueStr, unit] of matches) {
37+
if (seenUnits.has(unit)) {
38+
throw new Error(`duplicate unit '${unit}' in time delta: +${rest}`);
39+
}
40+
seenUnits.add(unit);
41+
const value = parseInt(valueStr, 10);
42+
if (unit === "mo") delta.months = value;
43+
else if (unit === "w") delta.weeks = value;
44+
else if (unit === "d") delta.days = value;
45+
else if (unit === "h") delta.hours = value;
46+
}
47+
return delta;
48+
}
49+
50+
/**
51+
* Resolves a relative stop-time delta (e.g. "+48h") to an absolute Date, relative to baseTime.
52+
* Mirrors pkg/workflow/stop_after.go's resolveStopTime: months and days/weeks are applied
53+
* together in a single calendar computation (so date-normalization overflow, e.g. Jan 31 + 1mo,
54+
* is resolved consistently), then hours are added on top.
55+
* @param {string} deltaStr
56+
* @param {Date} baseTime
57+
*/
58+
function resolveRelativeStopTime(deltaStr, baseTime) {
59+
const delta = parseTimeDeltaForStopAfter(deltaStr);
60+
const totalDays = delta.weeks * 7 + delta.days;
61+
return new Date(
62+
Date.UTC(baseTime.getUTCFullYear(), baseTime.getUTCMonth() + delta.months, baseTime.getUTCDate() + totalDays, baseTime.getUTCHours() + delta.hours, baseTime.getUTCMinutes(), baseTime.getUTCSeconds(), baseTime.getUTCMilliseconds())
63+
);
64+
}
65+
666
async function main() {
767
const stopTime = process.env.GH_AW_STOP_TIME;
868
const workflowName = process.env.GH_AW_WORKFLOW_NAME;
@@ -19,8 +79,21 @@ async function main() {
1979

2080
core.info(`Checking stop-time limit: ${stopTime}`);
2181

22-
// Parse the stop time (format: "YYYY-MM-DD HH:MM:SS")
23-
const stopTimeDate = new Date(stopTime);
82+
// Resolve the stop time. A GitHub Actions expression (e.g. "${{ inputs.stop-after }}")
83+
// is passed through verbatim at compile time and evaluated by the runner before this
84+
// step runs, so it may still be a relative delta (e.g. "+48h") rather than an already
85+
// resolved absolute timestamp (format: "YYYY-MM-DD HH:MM:SS").
86+
let stopTimeDate;
87+
if (isRelativeStopTime(stopTime)) {
88+
try {
89+
stopTimeDate = resolveRelativeStopTime(stopTime, new Date());
90+
} catch (err) {
91+
core.setFailed(`${ERR_VALIDATION}: Invalid stop-time format: ${stopTime}. ${err instanceof Error ? err.message : String(err)}`);
92+
return;
93+
}
94+
} else {
95+
stopTimeDate = new Date(stopTime);
96+
}
2497

2598
if (Number.isNaN(stopTimeDate.getTime())) {
2699
core.setFailed(`${ERR_VALIDATION}: Invalid stop-time format: ${stopTime}. Expected format: YYYY-MM-DD HH:MM:SS`);

actions/setup/js/check_stop_time.test.cjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,5 +97,28 @@ const mockCore = {
9797
expect(mockCore.setOutput).toHaveBeenCalledWith("stop_time_ok", "false"),
9898
expect(mockCore.setFailed).not.toHaveBeenCalled());
9999
});
100+
}),
101+
describe("when stop time is a relative delta (e.g. resolved from a GitHub Actions expression)", () => {
102+
it("should allow execution for a future relative delta such as +48h", async () => {
103+
((process.env.GH_AW_STOP_TIME = "+48h"),
104+
(process.env.GH_AW_WORKFLOW_NAME = "test-workflow"),
105+
await eval(`(async () => { ${checkStopTimeScript}; await main(); })()`),
106+
expect(mockCore.setOutput).toHaveBeenCalledWith("stop_time_ok", "true"),
107+
expect(mockCore.setFailed).not.toHaveBeenCalled());
108+
});
109+
it("should support combined units such as +1d12h", async () => {
110+
((process.env.GH_AW_STOP_TIME = "+1d12h"),
111+
(process.env.GH_AW_WORKFLOW_NAME = "test-workflow"),
112+
await eval(`(async () => { ${checkStopTimeScript}; await main(); })()`),
113+
expect(mockCore.setOutput).toHaveBeenCalledWith("stop_time_ok", "true"),
114+
expect(mockCore.setFailed).not.toHaveBeenCalled());
115+
});
116+
it("should fail with a descriptive error for an invalid relative delta", async () => {
117+
((process.env.GH_AW_STOP_TIME = "+5x"),
118+
(process.env.GH_AW_WORKFLOW_NAME = "test-workflow"),
119+
await eval(`(async () => { ${checkStopTimeScript}; await main(); })()`),
120+
expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid stop-time format")),
121+
expect(mockCore.setOutput).not.toHaveBeenCalled());
122+
});
100123
}));
101124
}));
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# ADR-56983: Add Typed `on.stop-after` and Runtime Expressions
2+
3+
**Date**: 2026-08-29
4+
**Status**: Draft
5+
**Deciders**: pelikhan, adr-writer agent
6+
7+
---
8+
9+
### Context
10+
11+
This pull request changes workflow frontmatter handling in `pkg/workflow` so `on.stop-after` is no longer interpreted only through the dynamic `On` map and can also accept GitHub Actions expressions such as `${{ inputs.stop-after }}`. The PR description identifies a drift risk between typed config, parser behavior, and documentation because `stop-after` was documented and consumed at runtime but had no dedicated typed field. The existing compile-time stop-after resolution logic also rejected expression-based values even when those values should be deferred to workflow runtime. Because this PR adds more than 100 lines in business-logic directories and changes parser/compiler behavior, the underlying design decision should be recorded explicitly.
12+
13+
### Decision
14+
15+
We will add a typed `OnStopAfter` field to `FrontmatterConfig`, centralize `on.stop-after` extraction in a shared parser helper, and treat GitHub Actions expressions for `stop-after` as runtime-resolved values that pass through compilation unchanged. We chose this approach to eliminate schema/parser/docs drift, keep typed and untyped frontmatter access paths consistent, and allow parameterized stop times without forcing compile-time parsing of runtime expressions. Literal relative and absolute stop-after values will continue to be resolved using the existing compiler behavior.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Keep `stop-after` Dynamic-Only in `on` Map
20+
21+
Continue reading `on.stop-after` only from `map[string]any` and leave typed config without a dedicated field.
22+
23+
This was considered because it would require the fewest structural changes to frontmatter parsing. It was not chosen because the PR evidence shows this has already created typed-schema and documentation drift risk, and separate access paths make it easier for parser behavior to diverge over time.
24+
25+
#### Alternative 2: Require All `stop-after` Values to Be Compile-Time Literals
26+
27+
Preserve the existing behavior that parses every `stop-after` value as a relative delta or absolute timestamp during compilation.
28+
29+
This was considered because compile-time normalization gives early validation and a single resolved representation in generated workflows. It was not chosen because GitHub Actions expressions are legitimate runtime inputs for workflow dispatch and should not be rejected merely because they cannot be resolved at compile time.
30+
31+
### Consequences
32+
33+
#### Positive
34+
- Typed frontmatter now exposes `on.stop-after` explicitly, reducing drift between config structs, parser behavior, schema text, and documentation.
35+
- A shared parsing helper makes typed population and runtime extraction use the same interpretation logic, lowering the risk of inconsistent behavior.
36+
- Workflows can accept expression-based `stop-after` values such as `${{ inputs.stop-after }}`, enabling runtime parameterization for dispatch inputs.
37+
38+
#### Negative
39+
- Stop-after handling now has two execution modes: compile-time resolution for literals and runtime passthrough for expressions, which increases conceptual complexity.
40+
- Expression-based values defer some validation until workflow runtime, so certain user errors will no longer be caught during compilation.
41+
- Adding another typed frontmatter field increases the maintenance surface of `FrontmatterConfig` and its parsing/tests.
42+
43+
#### Neutral
44+
- Existing literal `stop-after` formats remain supported; this change extends accepted inputs rather than replacing them.
45+
- The implementation requires coordinated updates across parser code, schema descriptions, generated docs, and tests.
46+
- Runtime workflow semantics change only for expression inputs; absolute and relative literal values continue through the established resolution path.
47+
48+
---
49+
50+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

docs/src/content/docs/reference/frontmatter-full.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -859,10 +859,11 @@ on:
859859
{}
860860

861861
# Time when workflow should stop running. Supports multiple formats: absolute
862-
# dates (YYYY-MM-DD HH:MM:SS, June 1 2025, 1st June 2025, 06/01/2025, etc.) or
863-
# relative time deltas (+25h, +3d, +1d12h30m). Maximum values for time deltas:
864-
# 12mo, 52w, 365d, 8760h (365 days). Note: Minute unit 'm' is not allowed for
865-
# stop-after; minimum unit is hours 'h'.
862+
# dates (YYYY-MM-DD HH:MM:SS, June 1 2025, 1st June 2025, 06/01/2025, etc.),
863+
# relative time deltas (+25h, +3d, +1d12h30m), or a GitHub Actions expression
864+
# (e.g. ${{ inputs.stop-after }}) resolved at workflow runtime. Maximum values for
865+
# time deltas: 12mo, 52w, 365d, 8760h (365 days). Note: Minute unit 'm' is not
866+
# allowed for stop-after; minimum unit is hours 'h'.
866867
# (optional)
867868
stop-after: "example-value"
868869

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1933,7 +1933,7 @@
19331933
},
19341934
"stop-after": {
19351935
"type": "string",
1936-
"description": "Time when workflow should stop running. Supports multiple formats: absolute dates (YYYY-MM-DD HH:MM:SS, June 1 2025, 1st June 2025, 06/01/2025, etc.) or relative time deltas (+25h, +3d, +1d12h30m). Maximum values for time deltas: 12mo, 52w, 365d, 8760h (365 days). Note: Minute unit 'm' is not allowed for stop-after; minimum unit is hours 'h'."
1936+
"description": "Time when workflow should stop running. Supports multiple formats: absolute dates (YYYY-MM-DD HH:MM:SS, June 1 2025, 1st June 2025, 06/01/2025, etc.), relative time deltas (+25h, +3d, +1d12h30m), or a GitHub Actions expression (e.g. ${{ inputs.stop-after }}) resolved at workflow runtime. Maximum values for time deltas: 12mo, 52w, 365d, 8760h (365 days). Note: Minute unit 'm' is not allowed for stop-after; minimum unit is hours 'h'."
19371937
},
19381938
"skip-if-match": {
19391939
"oneOf": [

pkg/workflow/frontmatter_parsing.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,17 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err
114114
}
115115
}
116116

117+
// Parse typed on.stop-after field if on exists. Parse errors (e.g. wrong type) are
118+
// intentionally not fatal here: extractStopAfterFromOn re-validates the raw value
119+
// and returns the actual compile error at the point stop-after is consumed.
120+
if len(config.On) > 0 {
121+
stopAfter, err := parseOnStopAfterValue(config.On)
122+
if err == nil {
123+
config.OnStopAfter = stopAfter
124+
frontmatterTypesLog.Printf("Parsed typed on.stop-after config: %q", stopAfter)
125+
}
126+
}
127+
117128
// Populate typed ExperimentConfigs from the raw frontmatter map so that both the
118129
// legacy bare-array form and the new object form are available as ExperimentConfig
119130
// structs without callers needing to type-assert config.Experiments entries.

pkg/workflow/frontmatter_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ type FrontmatterConfig struct {
390390
// Event and trigger configuration
391391
On map[string]any `json:"on,omitempty"` // Complex trigger config with many variants (too dynamic to type)
392392
OnNeeds []string `json:"-"` // New typed field extracted from on.needs (not in JSON to avoid conflict)
393+
OnStopAfter string `json:"-"` // Typed field extracted from on.stop-after (not in JSON to avoid conflict). Accepts a relative delta ("+25h"), an absolute timestamp, or a GitHub Actions expression (e.g. "${{ inputs.stop-after }}").
393394
Permissions map[string]any `json:"permissions,omitempty"` // Deprecated: use PermissionsTyped (can be string or map)
394395
Concurrency map[string]any `json:"concurrency,omitempty"`
395396
If string `json:"if,omitempty"`

0 commit comments

Comments
 (0)