@akshatmittal/invoker 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -13
- package/dist/github.d.mts +22 -0
- package/dist/github.mjs +353 -0
- package/dist/index.d.mts +6 -12
- package/dist/index.mjs +79 -62
- package/dist/slack.d.mts +10 -0
- package/dist/slack.mjs +482 -0
- package/package.json +22 -1
package/README.md
CHANGED
|
@@ -10,7 +10,91 @@ validated JSON Output in Vitest metadata for reporters and later analysis.
|
|
|
10
10
|
pnpm add -D @akshatmittal/invoker vitest
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Invoker supports Node 24 and Vitest 4.1.10 or newer within Vitest 4.
|
|
13
|
+
Invoker supports Node 24.18.1 or newer within Node 24 and Vitest 4.1.10 or newer within Vitest 4.
|
|
14
|
+
|
|
15
|
+
## Schedule GitHub Actions
|
|
16
|
+
|
|
17
|
+
`@akshatmittal/invoker/github` runs code-defined GitHub Actions schedules from
|
|
18
|
+
a small, long-running Node process. It is independent from the Vitest SDK, so a
|
|
19
|
+
scheduler-only installation does not need Vitest.
|
|
20
|
+
|
|
21
|
+
Create a GitHub App with repository **Actions: read and write**, disable its
|
|
22
|
+
webhook, install it on the selected repositories, and generate a private key.
|
|
23
|
+
No other repository, organization, user, OAuth, or webhook permissions are
|
|
24
|
+
needed.
|
|
25
|
+
|
|
26
|
+
Install the scheduler with t3-env and Zod in a plain ESM application:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install @akshatmittal/invoker @t3-oss/env-core zod
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
// schedule.mjs
|
|
34
|
+
import { createEnv } from "@t3-oss/env-core";
|
|
35
|
+
import { defineGitHubSchedule } from "@akshatmittal/invoker/github";
|
|
36
|
+
import { z } from "zod";
|
|
37
|
+
|
|
38
|
+
const env = createEnv({
|
|
39
|
+
server: {
|
|
40
|
+
GITHUB_APP_ID: z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
41
|
+
GITHUB_APP_PRIVATE_KEY: z.string().min(1),
|
|
42
|
+
},
|
|
43
|
+
runtimeEnv: process.env,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await defineGitHubSchedule({
|
|
47
|
+
app: {
|
|
48
|
+
id: env.GITHUB_APP_ID,
|
|
49
|
+
privateKey: env.GITHUB_APP_PRIVATE_KEY,
|
|
50
|
+
},
|
|
51
|
+
schedules: [
|
|
52
|
+
{
|
|
53
|
+
cron: "0 9 * * 1",
|
|
54
|
+
timezone: "UTC",
|
|
55
|
+
repository: "acme/regressions",
|
|
56
|
+
workflow: "invoker.yml",
|
|
57
|
+
ref: "main",
|
|
58
|
+
inputs: { dataset: "weekly", publish: true },
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Schedules use five-field cron expressions and default to UTC when `timezone`
|
|
65
|
+
is omitted. Configuration is fixed at startup. Every repository installation
|
|
66
|
+
and active workflow is validated before timers begin; GitHub validates the ref,
|
|
67
|
+
`workflow_dispatch` declaration, and input schema when a Dispatch is due.
|
|
68
|
+
|
|
69
|
+
Run exactly one replica. Dispatches may overlap, are not persisted or retried,
|
|
70
|
+
and failures do not stop later occurrences. The process emits events through
|
|
71
|
+
evlog's shared logger, so the host's filtering, sampling, redaction, and drain
|
|
72
|
+
configuration applies. The module does not initialize or configure evlog.
|
|
73
|
+
`SIGINT` and `SIGTERM` stop new Dispatches, await in-flight requests, and
|
|
74
|
+
resolve the long-running promise.
|
|
75
|
+
|
|
76
|
+
### Docker
|
|
77
|
+
|
|
78
|
+
Keep `package.json`, `package-lock.json`, and `schedule.mjs` in a deployment
|
|
79
|
+
directory and build this image:
|
|
80
|
+
|
|
81
|
+
```dockerfile
|
|
82
|
+
FROM node:24-slim
|
|
83
|
+
|
|
84
|
+
WORKDIR /app
|
|
85
|
+
COPY package.json package-lock.json ./
|
|
86
|
+
RUN npm ci --omit=dev
|
|
87
|
+
COPY schedule.mjs ./
|
|
88
|
+
|
|
89
|
+
USER node
|
|
90
|
+
CMD ["node", "schedule.mjs"]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Exclude `.env`, PEM, and private-key files from the build context. Inject
|
|
94
|
+
`GITHUB_APP_ID` and the real multiline `GITHUB_APP_PRIVATE_KEY` through the
|
|
95
|
+
runtime platform's secret mechanism; never bake them into the image. Use the
|
|
96
|
+
platform's process-liveness check and restart policy with one replica. The
|
|
97
|
+
scheduler intentionally has no HTTP health endpoint.
|
|
14
98
|
|
|
15
99
|
## Define a Workflow
|
|
16
100
|
|
|
@@ -20,10 +104,10 @@ import { defineTask } from "@akshatmittal/invoker";
|
|
|
20
104
|
|
|
21
105
|
export const evaluateModels = defineTask({
|
|
22
106
|
name: "evaluate-models",
|
|
23
|
-
matrix: {
|
|
107
|
+
matrix: async () => ({
|
|
24
108
|
model: ["gpt-5", "gpt-5-mini"],
|
|
25
109
|
dataset: ["support", "sales"],
|
|
26
|
-
},
|
|
110
|
+
}),
|
|
27
111
|
setup: async ({ cases }) => loadFixtures(cases),
|
|
28
112
|
run: async ({ matrix, setup, vitest }) => {
|
|
29
113
|
vitest.expect(setup.has(matrix.dataset)).toBe(true);
|
|
@@ -41,9 +125,9 @@ export const evaluateModels = defineTask({
|
|
|
41
125
|
```
|
|
42
126
|
|
|
43
127
|
```ts
|
|
44
|
-
// regressions/model
|
|
128
|
+
// regressions/workflows/model-regressions.test.ts
|
|
45
129
|
import { defineWorkflow } from "@akshatmittal/invoker";
|
|
46
|
-
import { evaluateModels } from "
|
|
130
|
+
import { evaluateModels } from "../tasks/evaluate-models.js";
|
|
47
131
|
|
|
48
132
|
defineWorkflow({
|
|
49
133
|
name: "model-regressions",
|
|
@@ -55,16 +139,18 @@ defineWorkflow({
|
|
|
55
139
|
});
|
|
56
140
|
```
|
|
57
141
|
|
|
58
|
-
Matrix
|
|
142
|
+
The Matrix function runs during collection and its returned literal determines the exact
|
|
143
|
+
`matrix` type. `setup` determines the exact
|
|
59
144
|
shared setup type, and the exact JSON return type is retained on the Task.
|
|
145
|
+
Axis names must be non-empty, enumerable strings that are not array indexes.
|
|
60
146
|
Omitting `matrix` creates one Case with `{}`. Setup runs once per Task, Cases
|
|
61
147
|
within that Task run concurrently, and teardown runs once after successful
|
|
62
148
|
setup. Tasks run sequentially in their Workflow.
|
|
63
149
|
|
|
64
150
|
## Configure Vitest
|
|
65
151
|
|
|
66
|
-
|
|
67
|
-
|
|
152
|
+
The JSON reporter includes each Case's data at
|
|
153
|
+
`assertionResults[].meta.invoker`:
|
|
68
154
|
|
|
69
155
|
```ts
|
|
70
156
|
// vitest.config.ts
|
|
@@ -85,9 +171,13 @@ Run every Workflow or filter to one Task with ordinary Vitest commands:
|
|
|
85
171
|
|
|
86
172
|
```sh
|
|
87
173
|
pnpm vitest run
|
|
88
|
-
pnpm vitest run regressions/model
|
|
174
|
+
pnpm vitest run regressions/workflows/model-regressions.test.ts -t evaluate-models
|
|
89
175
|
```
|
|
90
176
|
|
|
177
|
+
Define additional Workflows in separate `*.test.ts` files. Vitest discovers
|
|
178
|
+
them automatically; Invoker does not scan directories or require a central
|
|
179
|
+
index.
|
|
180
|
+
|
|
91
181
|
The metadata envelope is stable and JSON-compatible:
|
|
92
182
|
|
|
93
183
|
```json
|
|
@@ -103,6 +193,48 @@ The metadata envelope is stable and JSON-compatible:
|
|
|
103
193
|
report remains authoritative for status, failures, timing, hierarchy, and
|
|
104
194
|
retries.
|
|
105
195
|
|
|
196
|
+
## Notify Slack
|
|
197
|
+
|
|
198
|
+
Invoker's optional Slack reporter posts one `Invoker Report` parent message per
|
|
199
|
+
Vitest run. It places additional Workflow cards in the message thread so every
|
|
200
|
+
Task table remains within Slack's row and character limits. Each card includes
|
|
201
|
+
aggregate results, Workflow metadata, and a table of Task counts and durations.
|
|
202
|
+
A shared footer contains the elapsed span from the first Case start to the final
|
|
203
|
+
Case completion, a localized timestamp, and the optional run link. Final
|
|
204
|
+
failures are posted in the same thread with one reply per failed Task in each
|
|
205
|
+
Workflow. Unhandled run errors are reported once. Delivery failures are
|
|
206
|
+
isolated to the affected reply. Ambiguous transport failures are not retried;
|
|
207
|
+
an explicit Slack rate-limit rejection is reattempted only after its required
|
|
208
|
+
delay.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import { slackReporter } from "@akshatmittal/invoker/slack";
|
|
212
|
+
import { defineConfig } from "vitest/config";
|
|
213
|
+
|
|
214
|
+
const runUrl =
|
|
215
|
+
process.env.GITHUB_ACTIONS === "true"
|
|
216
|
+
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
|
217
|
+
: undefined;
|
|
218
|
+
|
|
219
|
+
export default defineConfig({
|
|
220
|
+
test: {
|
|
221
|
+
reporters: [
|
|
222
|
+
"tree",
|
|
223
|
+
slackReporter({
|
|
224
|
+
token: process.env.SLACK_BOT_TOKEN!,
|
|
225
|
+
channel: process.env.SLACK_CHANNEL_ID!,
|
|
226
|
+
runUrl,
|
|
227
|
+
}),
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Create a Slack app with the `chat:write` bot scope, install it to the workspace,
|
|
234
|
+
and expose its bot token and target channel ID as `SLACK_BOT_TOKEN` and
|
|
235
|
+
`SLACK_CHANNEL_ID`. Invite the bot to private target channels. Slack delivery
|
|
236
|
+
failures produce a warning but do not change Vitest's exit status.
|
|
237
|
+
|
|
106
238
|
## GitHub Actions artifacts
|
|
107
239
|
|
|
108
240
|
Create the output directory before Vitest and upload the report even when the
|
|
@@ -122,7 +254,7 @@ queries or reports. Invoker does not upload, index, or persist results itself.
|
|
|
122
254
|
|
|
123
255
|
## v1 scope
|
|
124
256
|
|
|
125
|
-
Invoker does not provide a CLI, directory discovery, custom runner,
|
|
126
|
-
reporter, configuration helper, Task-level parallelism, matrix
|
|
127
|
-
or hosted result storage. Use Vitest configuration and your CI
|
|
128
|
-
concerns.
|
|
257
|
+
Invoker does not provide a CLI, directory discovery, custom runner, general
|
|
258
|
+
reporter framework, configuration helper, Task-level parallelism, matrix
|
|
259
|
+
include/exclude, or hosted result storage. Use Vitest configuration and your CI
|
|
260
|
+
runner for those concerns.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/github/types.d.ts
|
|
2
|
+
type WorkflowInput = string | number | boolean;
|
|
3
|
+
type GitHubSchedule = {
|
|
4
|
+
readonly cron: string;
|
|
5
|
+
readonly timezone?: string;
|
|
6
|
+
readonly repository: string;
|
|
7
|
+
readonly workflow: string | number;
|
|
8
|
+
readonly ref: string;
|
|
9
|
+
readonly inputs?: Readonly<Record<string, WorkflowInput>>;
|
|
10
|
+
};
|
|
11
|
+
type GitHubScheduleDefinition = {
|
|
12
|
+
readonly app: {
|
|
13
|
+
readonly id: number;
|
|
14
|
+
readonly privateKey: string;
|
|
15
|
+
};
|
|
16
|
+
readonly schedules: readonly [GitHubSchedule, ...GitHubSchedule[]];
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/github.d.ts
|
|
20
|
+
declare function defineGitHubSchedule(definition: GitHubScheduleDefinition): Promise<void>;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { defineGitHubSchedule };
|
package/dist/github.mjs
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Cron } from "croner";
|
|
3
|
+
import { createAppAuth } from "@octokit/auth-app";
|
|
4
|
+
import { request } from "@octokit/request";
|
|
5
|
+
import { log } from "evlog";
|
|
6
|
+
//#region src/github/client.ts
|
|
7
|
+
const API_VERSION = "2026-03-10";
|
|
8
|
+
const dispatchResponseSchema = z.object({
|
|
9
|
+
workflow_run_id: z.int().positive(),
|
|
10
|
+
html_url: z.url()
|
|
11
|
+
});
|
|
12
|
+
const requestFailureSchema = z.object({
|
|
13
|
+
status: z.int().optional(),
|
|
14
|
+
response: z.object({ headers: z.object({ "x-github-request-id": z.string().optional() }) }).optional()
|
|
15
|
+
});
|
|
16
|
+
const githubFailures = /* @__PURE__ */ new WeakMap();
|
|
17
|
+
function createGitHubClient(app, signal) {
|
|
18
|
+
const apiRequest = request.defaults({
|
|
19
|
+
headers: {
|
|
20
|
+
accept: "application/vnd.github+json",
|
|
21
|
+
"x-github-api-version": API_VERSION
|
|
22
|
+
},
|
|
23
|
+
request: { signal }
|
|
24
|
+
});
|
|
25
|
+
const auth = createAppAuth({
|
|
26
|
+
appId: app.id,
|
|
27
|
+
privateKey: app.privateKey,
|
|
28
|
+
request: apiRequest,
|
|
29
|
+
log: { warn() {} }
|
|
30
|
+
});
|
|
31
|
+
const resolveInstallation = async (target) => {
|
|
32
|
+
try {
|
|
33
|
+
const authentication = await auth({ type: "app" });
|
|
34
|
+
return (await apiRequest("GET /repos/{owner}/{repo}/installation", {
|
|
35
|
+
owner: target.owner,
|
|
36
|
+
repo: target.repo,
|
|
37
|
+
headers: { authorization: `Bearer ${authentication.token}` }
|
|
38
|
+
})).data.id;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw githubError(githubFailure(error, "resolve installation", target));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const installationToken = async (target, installationId) => {
|
|
44
|
+
try {
|
|
45
|
+
return (await auth({
|
|
46
|
+
type: "installation",
|
|
47
|
+
installationId,
|
|
48
|
+
repositoryNames: [target.repo],
|
|
49
|
+
permissions: { actions: "write" }
|
|
50
|
+
})).token;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw githubError(githubFailure(error, "create installation token", target));
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const resolveWorkflow = async (target, installationId) => {
|
|
56
|
+
try {
|
|
57
|
+
const token = await installationToken(target, installationId);
|
|
58
|
+
const response = await apiRequest("GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}", {
|
|
59
|
+
owner: target.owner,
|
|
60
|
+
repo: target.repo,
|
|
61
|
+
workflow_id: target.workflow,
|
|
62
|
+
headers: { authorization: `Bearer ${token}` }
|
|
63
|
+
});
|
|
64
|
+
if (response.data.state !== "active") throw new Error("workflow is not active");
|
|
65
|
+
return response.data.id;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw githubError(githubFailure(error, "resolve workflow", target));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const dispatch = async (target) => {
|
|
71
|
+
try {
|
|
72
|
+
const token = await installationToken(target, target.installationId);
|
|
73
|
+
const response = await apiRequest("POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", {
|
|
74
|
+
owner: target.owner,
|
|
75
|
+
repo: target.repo,
|
|
76
|
+
workflow_id: target.workflowId,
|
|
77
|
+
ref: target.ref,
|
|
78
|
+
return_run_details: true,
|
|
79
|
+
inputs: target.inputs,
|
|
80
|
+
headers: { authorization: `Bearer ${token}` }
|
|
81
|
+
});
|
|
82
|
+
const data = dispatchResponseSchema.parse(response.data);
|
|
83
|
+
return {
|
|
84
|
+
runId: data.workflow_run_id,
|
|
85
|
+
webUrl: data.html_url
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw githubError(githubFailure(error, "dispatch workflow", target));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
dispatch,
|
|
93
|
+
resolveInstallation,
|
|
94
|
+
resolveWorkflow
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function githubFailure(cause, operation, target) {
|
|
98
|
+
if (cause instanceof Error) {
|
|
99
|
+
const known = githubFailures.get(cause);
|
|
100
|
+
if (known) return known;
|
|
101
|
+
}
|
|
102
|
+
const parsed = requestFailureSchema.safeParse(cause);
|
|
103
|
+
const status = parsed.success ? parsed.data.status : void 0;
|
|
104
|
+
const requestId = parsed.success ? parsed.data.response?.headers["x-github-request-id"] : void 0;
|
|
105
|
+
return {
|
|
106
|
+
message: `GitHub ${operation}${target ? ` for ${target.repository} workflow ${String(target.workflow)}` : ""}: ${status === 404 ? "not found or inaccessible" : "request failed"}${status === void 0 ? "" : ` (${status})`}${requestId === void 0 ? "" : ` [request ${requestId}]`}`,
|
|
107
|
+
operation,
|
|
108
|
+
repository: target?.repository,
|
|
109
|
+
workflow: target?.workflow,
|
|
110
|
+
status,
|
|
111
|
+
requestId
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function githubError(failure) {
|
|
115
|
+
const error = new Error(failure.message);
|
|
116
|
+
githubFailures.set(error, failure);
|
|
117
|
+
return error;
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/github/config.ts
|
|
121
|
+
const GITHUB_SCHEDULE_OWNER = "GitHub Schedule";
|
|
122
|
+
const inputSchema = z.union([
|
|
123
|
+
z.string(),
|
|
124
|
+
z.number().finite(),
|
|
125
|
+
z.boolean()
|
|
126
|
+
]);
|
|
127
|
+
const timezoneSchema = z.string().refine((timezone) => {
|
|
128
|
+
try {
|
|
129
|
+
new Intl.DateTimeFormat(void 0, { timeZone: timezone });
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}, "expected a valid IANA timezone");
|
|
135
|
+
const scheduleSchema = z.strictObject({
|
|
136
|
+
cron: z.string().min(1),
|
|
137
|
+
timezone: timezoneSchema.default("UTC"),
|
|
138
|
+
repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/),
|
|
139
|
+
workflow: z.union([z.int().positive(), z.string().min(1).refine((value) => value.trim() === value && !value.includes("/"))]),
|
|
140
|
+
ref: z.string().refine((value) => value.trim() !== ""),
|
|
141
|
+
inputs: z.record(z.string(), inputSchema).refine((inputs) => Object.keys(inputs).length <= 25).refine((inputs) => JSON.stringify(inputs).length <= 65535).optional()
|
|
142
|
+
});
|
|
143
|
+
const definitionSchema = z.strictObject({
|
|
144
|
+
app: z.strictObject({
|
|
145
|
+
id: z.int().positive(),
|
|
146
|
+
privateKey: z.string().min(1)
|
|
147
|
+
}),
|
|
148
|
+
schedules: z.array(scheduleSchema).min(1)
|
|
149
|
+
});
|
|
150
|
+
function normalizeDefinition(value) {
|
|
151
|
+
const result = definitionSchema.safeParse(value);
|
|
152
|
+
if (!result.success) throw new TypeError(`${GITHUB_SCHEDULE_OWNER}: ${z.prettifyError(result.error)}`);
|
|
153
|
+
const schedules = result.data.schedules.map((schedule, index) => {
|
|
154
|
+
let validationJob;
|
|
155
|
+
try {
|
|
156
|
+
validationJob = new Cron(schedule.cron, {
|
|
157
|
+
mode: "5-part",
|
|
158
|
+
paused: true,
|
|
159
|
+
timezone: schedule.timezone
|
|
160
|
+
});
|
|
161
|
+
validationJob.nextRun();
|
|
162
|
+
} catch {
|
|
163
|
+
throw new TypeError(`${GITHUB_SCHEDULE_OWNER}.schedules[${index}].cron: expected a valid five-field cron expression`);
|
|
164
|
+
} finally {
|
|
165
|
+
validationJob?.stop();
|
|
166
|
+
}
|
|
167
|
+
const separator = schedule.repository.indexOf("/");
|
|
168
|
+
const owner = schedule.repository.slice(0, separator);
|
|
169
|
+
const repo = schedule.repository.slice(separator + 1);
|
|
170
|
+
return {
|
|
171
|
+
...schedule,
|
|
172
|
+
owner,
|
|
173
|
+
repo
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
app: result.data.app,
|
|
178
|
+
schedules
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function workflowKey(schedule) {
|
|
182
|
+
return `${schedule.repository}\0${String(schedule.workflow)}`;
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/github/events.ts
|
|
186
|
+
const SERVICE = "github-schedule";
|
|
187
|
+
function logStartup(schedules, repositories, workflows, failure) {
|
|
188
|
+
log[failure ? "error" : "info"]({
|
|
189
|
+
service: SERVICE,
|
|
190
|
+
event: "github_schedule.startup",
|
|
191
|
+
schedules,
|
|
192
|
+
repositories,
|
|
193
|
+
workflows,
|
|
194
|
+
...failure ? {
|
|
195
|
+
outcome: "failure",
|
|
196
|
+
failure
|
|
197
|
+
} : { outcome: "success" }
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
function logDispatch(schedule, scheduledAt, result, failure) {
|
|
201
|
+
log[failure ? "error" : "info"]({
|
|
202
|
+
service: SERVICE,
|
|
203
|
+
event: "github_schedule.dispatch",
|
|
204
|
+
repository: schedule.repository,
|
|
205
|
+
workflow: schedule.workflow,
|
|
206
|
+
ref: schedule.ref,
|
|
207
|
+
cron: schedule.cron,
|
|
208
|
+
timezone: schedule.timezone,
|
|
209
|
+
scheduledAt,
|
|
210
|
+
...failure ? {
|
|
211
|
+
outcome: "failure",
|
|
212
|
+
failure
|
|
213
|
+
} : {
|
|
214
|
+
outcome: "success",
|
|
215
|
+
runId: result.runId,
|
|
216
|
+
runUrl: result.webUrl
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function logShutdown(signal, drainedDispatches) {
|
|
221
|
+
log.info({
|
|
222
|
+
service: SERVICE,
|
|
223
|
+
event: "github_schedule.shutdown",
|
|
224
|
+
signal,
|
|
225
|
+
drainedDispatches,
|
|
226
|
+
outcome: "success"
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/github/scheduler.ts
|
|
231
|
+
let schedulerActive = false;
|
|
232
|
+
async function runGitHubSchedule(definition) {
|
|
233
|
+
let normalized;
|
|
234
|
+
try {
|
|
235
|
+
normalized = normalizeDefinition(definition);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
logStartup(0, 0, 0, localFailure(error));
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
const repositoryCount = new Set(normalized.schedules.map(({ repository }) => repository)).size;
|
|
241
|
+
const workflowCount = new Set(normalized.schedules.map(workflowKey)).size;
|
|
242
|
+
if (schedulerActive) {
|
|
243
|
+
const error = /* @__PURE__ */ new TypeError(`${GITHUB_SCHEDULE_OWNER}: another scheduler is already active`);
|
|
244
|
+
logStartup(normalized.schedules.length, repositoryCount, workflowCount, localFailure(error));
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
schedulerActive = true;
|
|
248
|
+
const startupAbort = new AbortController();
|
|
249
|
+
const jobs = [];
|
|
250
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
251
|
+
let state = "starting";
|
|
252
|
+
let stopSignal;
|
|
253
|
+
let drainedCount = 0;
|
|
254
|
+
let resolveStop;
|
|
255
|
+
const stopRequested = new Promise((resolve) => {
|
|
256
|
+
resolveStop = resolve;
|
|
257
|
+
});
|
|
258
|
+
const isStopping = () => state === "stopping";
|
|
259
|
+
const removeSignalHandlers = () => {
|
|
260
|
+
process.removeListener("SIGINT", onSigint);
|
|
261
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
262
|
+
};
|
|
263
|
+
const requestStop = (signal) => {
|
|
264
|
+
if (isStopping()) return;
|
|
265
|
+
const duringStartup = state === "starting";
|
|
266
|
+
state = "stopping";
|
|
267
|
+
stopSignal = signal;
|
|
268
|
+
drainedCount = inFlight.size;
|
|
269
|
+
jobs.forEach((job) => job.stop());
|
|
270
|
+
removeSignalHandlers();
|
|
271
|
+
if (duringStartup) startupAbort.abort();
|
|
272
|
+
resolveStop();
|
|
273
|
+
};
|
|
274
|
+
function onSigint() {
|
|
275
|
+
requestStop("SIGINT");
|
|
276
|
+
}
|
|
277
|
+
function onSigterm() {
|
|
278
|
+
requestStop("SIGTERM");
|
|
279
|
+
}
|
|
280
|
+
process.on("SIGINT", onSigint);
|
|
281
|
+
process.on("SIGTERM", onSigterm);
|
|
282
|
+
try {
|
|
283
|
+
const github = createGitHubClient(normalized.app, startupAbort.signal);
|
|
284
|
+
const installations = /* @__PURE__ */ new Map();
|
|
285
|
+
for (const schedule of normalized.schedules) if (!installations.has(schedule.repository)) installations.set(schedule.repository, await github.resolveInstallation(schedule));
|
|
286
|
+
const workflows = /* @__PURE__ */ new Map();
|
|
287
|
+
for (const schedule of normalized.schedules) {
|
|
288
|
+
const key = workflowKey(schedule);
|
|
289
|
+
if (!workflows.has(key)) workflows.set(key, await github.resolveWorkflow(schedule, installations.get(schedule.repository)));
|
|
290
|
+
}
|
|
291
|
+
if (isStopping()) {
|
|
292
|
+
await finishShutdown(inFlight, stopSignal, drainedCount);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const schedules = normalized.schedules.map((schedule) => ({
|
|
296
|
+
...schedule,
|
|
297
|
+
installationId: installations.get(schedule.repository),
|
|
298
|
+
workflowId: workflows.get(workflowKey(schedule))
|
|
299
|
+
}));
|
|
300
|
+
for (const schedule of schedules) {
|
|
301
|
+
const job = new Cron(schedule.cron, {
|
|
302
|
+
catch: true,
|
|
303
|
+
mode: "5-part",
|
|
304
|
+
timezone: schedule.timezone
|
|
305
|
+
}, (currentJob) => {
|
|
306
|
+
if (state !== "running") return;
|
|
307
|
+
const scheduledAt = (currentJob.currentRun() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
308
|
+
const dispatch = github.dispatch(schedule).then((result) => logDispatch(schedule, scheduledAt, result), (cause) => {
|
|
309
|
+
const failure = githubFailure(cause, "dispatch workflow", schedule);
|
|
310
|
+
logDispatch(schedule, scheduledAt, void 0, failure);
|
|
311
|
+
throw new Error(failure.message);
|
|
312
|
+
});
|
|
313
|
+
inFlight.add(dispatch);
|
|
314
|
+
dispatch.then(() => inFlight.delete(dispatch), () => inFlight.delete(dispatch));
|
|
315
|
+
return dispatch;
|
|
316
|
+
});
|
|
317
|
+
jobs.push(job);
|
|
318
|
+
}
|
|
319
|
+
state = "running";
|
|
320
|
+
logStartup(normalized.schedules.length, repositoryCount, workflowCount);
|
|
321
|
+
await stopRequested;
|
|
322
|
+
await finishShutdown(inFlight, stopSignal, drainedCount);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (isStopping()) {
|
|
325
|
+
await finishShutdown(inFlight, stopSignal, drainedCount);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
jobs.forEach((job) => job.stop());
|
|
329
|
+
removeSignalHandlers();
|
|
330
|
+
schedulerActive = false;
|
|
331
|
+
const failure = githubFailure(error, "start scheduler");
|
|
332
|
+
logStartup(normalized.schedules.length, repositoryCount, workflowCount, failure);
|
|
333
|
+
throw githubError(failure);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function finishShutdown(inFlight, signal, drainedCount) {
|
|
337
|
+
await Promise.allSettled(inFlight);
|
|
338
|
+
schedulerActive = false;
|
|
339
|
+
logShutdown(signal, drainedCount);
|
|
340
|
+
}
|
|
341
|
+
function localFailure(cause) {
|
|
342
|
+
return {
|
|
343
|
+
message: cause instanceof Error ? cause.message : `${GITHUB_SCHEDULE_OWNER}: startup validation failed`,
|
|
344
|
+
operation: "validate configuration"
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/github.ts
|
|
349
|
+
function defineGitHubSchedule(definition) {
|
|
350
|
+
return runGitHubSchedule(definition);
|
|
351
|
+
}
|
|
352
|
+
//#endregion
|
|
353
|
+
export { defineGitHubSchedule };
|
package/dist/index.d.mts
CHANGED
|
@@ -34,25 +34,22 @@ type TeardownContext<M extends Matrix, Setup> = SetupContext<M> & {
|
|
|
34
34
|
declare const taskDefinitionBrand: unique symbol;
|
|
35
35
|
interface TaskDefinition<Name extends string = string, M extends Matrix = Matrix, Setup = unknown, Output extends JsonValue = JsonValue> {
|
|
36
36
|
readonly name: Name;
|
|
37
|
-
readonly matrix: M
|
|
38
|
-
readonly [taskDefinitionBrand]:
|
|
39
|
-
readonly setup: Setup;
|
|
40
|
-
readonly output: Output;
|
|
41
|
-
};
|
|
37
|
+
readonly matrix: () => Promise<M>;
|
|
38
|
+
readonly [taskDefinitionBrand]: true;
|
|
42
39
|
readonly setup?: (context: SetupContext<M>) => Awaitable<Setup>;
|
|
43
40
|
readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
|
|
44
41
|
readonly teardown?: (context: TeardownContext<M, Setup>) => Awaitable<void>;
|
|
45
42
|
}
|
|
46
43
|
type TaskWithSetup<Name extends string, M extends Matrix, Setup, Output extends JsonValue> = {
|
|
47
44
|
readonly name: Name;
|
|
48
|
-
readonly matrix?: M
|
|
45
|
+
readonly matrix?: () => Promise<M>;
|
|
49
46
|
readonly setup: (context: SetupContext<M>) => Awaitable<Setup>;
|
|
50
47
|
readonly run: (context: TaskContext<CaseCoordinates<M>, Setup>) => Awaitable<Output>;
|
|
51
48
|
readonly teardown?: (context: TeardownContext<M, Setup>) => Awaitable<void>;
|
|
52
49
|
};
|
|
53
50
|
type TaskWithoutSetup<Name extends string, M extends Matrix, Output extends JsonValue> = {
|
|
54
51
|
readonly name: Name;
|
|
55
|
-
readonly matrix?: M
|
|
52
|
+
readonly matrix?: () => Promise<M>;
|
|
56
53
|
readonly setup?: never;
|
|
57
54
|
readonly run: (context: TaskContext<CaseCoordinates<M>, undefined>) => Awaitable<Output>;
|
|
58
55
|
readonly teardown?: never;
|
|
@@ -61,11 +58,8 @@ declare function defineTask<const Name extends string, const M extends Matrix =
|
|
|
61
58
|
declare function defineTask<const Name extends string, const M extends Matrix = Record<never, never>, const Output extends JsonValue = JsonValue>(definition: TaskWithoutSetup<Name, M, Output>): TaskDefinition<Name, M, undefined, Output>;
|
|
62
59
|
type AnyTaskDefinition = {
|
|
63
60
|
readonly name: string;
|
|
64
|
-
readonly matrix: Matrix
|
|
65
|
-
readonly [taskDefinitionBrand]:
|
|
66
|
-
readonly setup: unknown;
|
|
67
|
-
readonly output: JsonValue;
|
|
68
|
-
};
|
|
61
|
+
readonly matrix: () => Promise<Matrix>;
|
|
62
|
+
readonly [taskDefinitionBrand]: true;
|
|
69
63
|
};
|
|
70
64
|
//#endregion
|
|
71
65
|
//#region src/workflow.d.ts
|