@oisincoveney/pipeline 1.17.0 → 1.18.1
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/dist/commands/pipeline-command.js +28 -0
- package/dist/commands/runner-job-command.js +10 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +22 -0
- package/dist/hooks.d.ts +1 -1
- package/dist/index.js +7 -34
- package/dist/mcp/gateway.js +1 -0
- package/dist/runner-event-sink.js +14 -0
- package/dist/runner-job/delivery.js +57 -0
- package/dist/runner-job/devspace.js +34 -0
- package/dist/runner-job/run.js +279 -0
- package/dist/runner-job/workspace.js +52 -0
- package/dist/runner-job-contract.d.ts +89 -80
- package/dist/runner-job-contract.js +46 -68
- package/dist/runtime/changed-files/changed-files.js +2 -2
- package/dist/runtime/context/context.js +1 -1
- package/dist/runtime/drain-merge/drain-merge.js +2 -2
- package/dist/runtime/worktrees/worktrees.js +4 -4
- package/dist/runtime-machines/workflow-machine.js +1 -1
- package/dist/schedule-planner.js +2 -2
- package/docs/operator-guide.md +17 -11
- package/docs/pipeline-console-runner-contract.md +107 -37
- package/package.json +1 -1
- package/dist/kubernetes-runner.js +0 -152
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/commands/pipeline-command.ts
|
|
2
|
+
const BUILTIN_PIPE_COMMANDS = new Set([
|
|
3
|
+
"run",
|
|
4
|
+
"pipe",
|
|
5
|
+
"validate",
|
|
6
|
+
"explain-plan",
|
|
7
|
+
"doctor",
|
|
8
|
+
"init",
|
|
9
|
+
"install-commands",
|
|
10
|
+
"mcp",
|
|
11
|
+
"runner-job"
|
|
12
|
+
]);
|
|
13
|
+
function registerConfiguredEntrypointCommands(program, config, runEntrypoint) {
|
|
14
|
+
const registered = /* @__PURE__ */ new Set();
|
|
15
|
+
if (!config) return registered;
|
|
16
|
+
const reservedCommands = new Set(program.commands.map((command) => command.name()));
|
|
17
|
+
for (const [id, entrypoint] of Object.entries(config.entrypoints)) {
|
|
18
|
+
if (reservedCommands.has(id)) continue;
|
|
19
|
+
program.command(id).description(entrypoint.description ?? `Run the ${id} workflow`).argument("<description...>", "task description").action(async (descriptionParts) => {
|
|
20
|
+
await runEntrypoint(id, descriptionParts.join(" "));
|
|
21
|
+
});
|
|
22
|
+
registered.add(id);
|
|
23
|
+
reservedCommands.add(id);
|
|
24
|
+
}
|
|
25
|
+
return registered;
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { BUILTIN_PIPE_COMMANDS, registerConfiguredEntrypointCommands };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { runRunnerJob } from "../runner-job/run.js";
|
|
2
|
+
//#region src/commands/runner-job-command.ts
|
|
3
|
+
function registerRunnerJobCommand(program) {
|
|
4
|
+
program.command("runner-job").description("Run an in-pod pipeline runner job from the console payload").action(async () => {
|
|
5
|
+
const exitCode = await runRunnerJob();
|
|
6
|
+
process.exitCode = exitCode;
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
//#endregion
|
|
10
|
+
export { registerRunnerJobCommand };
|
package/dist/config.d.ts
CHANGED
|
@@ -309,6 +309,20 @@ declare const configSchema: z.ZodObject<{
|
|
|
309
309
|
task: "task";
|
|
310
310
|
}>>>;
|
|
311
311
|
}, z.core.$strict>>>;
|
|
312
|
+
runner_job: z.ZodDefault<z.ZodObject<{
|
|
313
|
+
environment: z.ZodDefault<z.ZodObject<{
|
|
314
|
+
setup: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
315
|
+
args: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
316
|
+
command: z.ZodString;
|
|
317
|
+
required: z.ZodDefault<z.ZodBoolean>;
|
|
318
|
+
}, z.core.$strict>>>;
|
|
319
|
+
smoke: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
320
|
+
args: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
321
|
+
command: z.ZodString;
|
|
322
|
+
required: z.ZodDefault<z.ZodBoolean>;
|
|
323
|
+
}, z.core.$strict>>>;
|
|
324
|
+
}, z.core.$strict>>;
|
|
325
|
+
}, z.core.$strict>>;
|
|
312
326
|
rules: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
313
327
|
path: z.ZodString;
|
|
314
328
|
}, z.core.$strict>>>;
|
package/dist/config.js
CHANGED
|
@@ -361,6 +361,19 @@ const workflowSchema = z.object({
|
|
|
361
361
|
execution: workflowExecutionSchema.optional(),
|
|
362
362
|
nodes: z.array(workflowNodeSchema)
|
|
363
363
|
}).strict();
|
|
364
|
+
const runnerJobCommandSchema = z.object({
|
|
365
|
+
args: z.array(z.string()).default([]),
|
|
366
|
+
command: z.string().min(1),
|
|
367
|
+
required: z.boolean().default(true)
|
|
368
|
+
}).strict();
|
|
369
|
+
const runnerJobEnvironmentSchema = z.object({
|
|
370
|
+
setup: z.array(runnerJobCommandSchema).default([]),
|
|
371
|
+
smoke: z.array(runnerJobCommandSchema).default([])
|
|
372
|
+
}).strict();
|
|
373
|
+
const runnerJobConfigSchema = z.object({ environment: runnerJobEnvironmentSchema.default({
|
|
374
|
+
setup: [],
|
|
375
|
+
smoke: []
|
|
376
|
+
}) }).strict();
|
|
364
377
|
const runnersFileSchema = z.object({
|
|
365
378
|
runners: strictRecord(runnerSchema).default({}),
|
|
366
379
|
version: z.literal(1)
|
|
@@ -381,6 +394,10 @@ const pipelineFileSchema = z.object({
|
|
|
381
394
|
on: {}
|
|
382
395
|
}),
|
|
383
396
|
orchestrator: orchestratorSchema,
|
|
397
|
+
runner_job: runnerJobConfigSchema.default({ environment: {
|
|
398
|
+
setup: [],
|
|
399
|
+
smoke: []
|
|
400
|
+
} }),
|
|
384
401
|
schedules: strictRecord(schedulePolicySchema).default({}),
|
|
385
402
|
task_context: taskContextResolverSchema.optional(),
|
|
386
403
|
workflows: strictRecord(workflowSchema).default({}),
|
|
@@ -397,6 +414,10 @@ const configSchema = z.object({
|
|
|
397
414
|
mcp_servers: strictRecord(mcpServerSchema).default({}),
|
|
398
415
|
orchestrator: orchestratorSchema,
|
|
399
416
|
profiles: strictRecord(profileSchema).default({}),
|
|
417
|
+
runner_job: runnerJobConfigSchema.default({ environment: {
|
|
418
|
+
setup: [],
|
|
419
|
+
smoke: []
|
|
420
|
+
} }),
|
|
400
421
|
rules: strictRecord(pathRefSchema).default({}),
|
|
401
422
|
runners: strictRecord(runnerSchema).default({}),
|
|
402
423
|
schedules: strictRecord(schedulePolicySchema).default({}),
|
|
@@ -519,6 +540,7 @@ function parsePipelineConfigParts(sources, projectRoot, sourcePaths = {
|
|
|
519
540
|
mcp_servers: profiles.mcp_servers,
|
|
520
541
|
orchestrator: pipeline.orchestrator,
|
|
521
542
|
profiles: profiles.profiles,
|
|
543
|
+
runner_job: pipeline.runner_job,
|
|
522
544
|
rules: profiles.rules,
|
|
523
545
|
runners: runners.runners,
|
|
524
546
|
schedules: pipeline.schedules,
|
package/dist/hooks.d.ts
CHANGED
|
@@ -13,8 +13,8 @@ declare const hookResultSchema: z.ZodObject<{
|
|
|
13
13
|
taskContext: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
14
14
|
}, z.core.$strict>>;
|
|
15
15
|
status: z.ZodEnum<{
|
|
16
|
-
pass: "pass";
|
|
17
16
|
fail: "fail";
|
|
17
|
+
pass: "pass";
|
|
18
18
|
skip: "skip";
|
|
19
19
|
}>;
|
|
20
20
|
summary: z.ZodOptional<z.ZodString>;
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { PipelineConfigError, loadPipelineConfig, tryLoadPipelineConfig } from "./config.js";
|
|
3
|
+
import { BUILTIN_PIPE_COMMANDS, registerConfiguredEntrypointCommands } from "./commands/pipeline-command.js";
|
|
3
4
|
import { configureGatewayHosts, localGatewayStatus, renderGatewayConfig, runGatewayDoctor, startLocalGateway } from "./mcp/gateway.js";
|
|
4
|
-
import { compileWorkflowPlan } from "./workflow-planner.js";
|
|
5
|
-
import { formatInstallCommandsResult, installCommands, parseCommandHost } from "./install-commands.js";
|
|
6
5
|
import { createOrchestratorLaunchPlan, createRunnerLaunchPlan } from "./runner.js";
|
|
6
|
+
import { compileWorkflowPlan } from "./workflow-planner.js";
|
|
7
7
|
import { formatConfigError, runPipelineFromConfig } from "./pipeline-runtime.js";
|
|
8
|
-
import { runKubernetesRunnerJob } from "./kubernetes-runner.js";
|
|
9
|
-
import { formatPipelineInitResult, initPipelineProject } from "./pipeline-init.js";
|
|
10
8
|
import { compileScheduleArtifact, generateScheduleArtifact, parseScheduleArtifact } from "./schedule-planner.js";
|
|
9
|
+
import { registerRunnerJobCommand } from "./commands/runner-job-command.js";
|
|
10
|
+
import { formatInstallCommandsResult, installCommands, parseCommandHost } from "./install-commands.js";
|
|
11
|
+
import { formatPipelineInitResult, initPipelineProject } from "./pipeline-init.js";
|
|
11
12
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
12
13
|
import { resolve } from "node:path";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
@@ -190,17 +191,6 @@ function truncateMiddle(text, maxLength) {
|
|
|
190
191
|
const keep = Math.floor((maxLength - 32) / 2);
|
|
191
192
|
return `${text.slice(0, keep)}\n... truncated ...\n${text.slice(-keep)}`;
|
|
192
193
|
}
|
|
193
|
-
const BUILTIN_PIPE_COMMANDS = new Set([
|
|
194
|
-
"run",
|
|
195
|
-
"pipe",
|
|
196
|
-
"validate",
|
|
197
|
-
"explain-plan",
|
|
198
|
-
"doctor",
|
|
199
|
-
"init",
|
|
200
|
-
"install-commands",
|
|
201
|
-
"mcp",
|
|
202
|
-
"runner-job"
|
|
203
|
-
]);
|
|
204
194
|
function createCliProgram() {
|
|
205
195
|
const configuredPipeline = tryLoadConfiguredEntrypoints(process.env.PIPELINE_TARGET_PATH ?? process.cwd());
|
|
206
196
|
const program = new Command();
|
|
@@ -284,11 +274,8 @@ function createCliProgram() {
|
|
|
284
274
|
});
|
|
285
275
|
console.log(formatInstallCommandsResult(result));
|
|
286
276
|
});
|
|
287
|
-
program
|
|
288
|
-
|
|
289
|
-
process.exitCode = exitCode;
|
|
290
|
-
});
|
|
291
|
-
const configuredEntrypointCommands = registerConfiguredEntrypointCommands(program, configuredPipeline);
|
|
277
|
+
registerRunnerJobCommand(program);
|
|
278
|
+
const configuredEntrypointCommands = registerConfiguredEntrypointCommands(program, configuredPipeline, (entrypoint, task) => pipe(task, { entrypoint }));
|
|
292
279
|
if (configuredEntrypointCommands.size > 0) program.configureHelp({ subcommandTerm(command) {
|
|
293
280
|
if (configuredEntrypointCommands.has(command.name())) return command.name();
|
|
294
281
|
return Help.prototype.subcommandTerm.call(this, command);
|
|
@@ -303,20 +290,6 @@ function tryLoadConfiguredEntrypoints(cwd) {
|
|
|
303
290
|
throw err;
|
|
304
291
|
}
|
|
305
292
|
}
|
|
306
|
-
function registerConfiguredEntrypointCommands(program, config) {
|
|
307
|
-
const registered = /* @__PURE__ */ new Set();
|
|
308
|
-
if (!config) return registered;
|
|
309
|
-
const reservedCommands = new Set(program.commands.map((command) => command.name()));
|
|
310
|
-
for (const [id, entrypoint] of Object.entries(config.entrypoints)) {
|
|
311
|
-
if (reservedCommands.has(id)) continue;
|
|
312
|
-
program.command(id).description(entrypoint.description ?? `Run the ${id} workflow`).argument("<description...>", "task description").action(async (descriptionParts) => {
|
|
313
|
-
await pipe(descriptionParts.join(" "), { entrypoint: id });
|
|
314
|
-
});
|
|
315
|
-
registered.add(id);
|
|
316
|
-
reservedCommands.add(id);
|
|
317
|
-
}
|
|
318
|
-
return registered;
|
|
319
|
-
}
|
|
320
293
|
function parseGatewayHostScope(value) {
|
|
321
294
|
if (value === "project" || value === "global") return value;
|
|
322
295
|
throw new Error("scope must be project or global");
|
package/dist/mcp/gateway.js
CHANGED
|
@@ -87,6 +87,20 @@ function createRunnerEventSink(options) {
|
|
|
87
87
|
workflowId
|
|
88
88
|
});
|
|
89
89
|
},
|
|
90
|
+
recordRunnerJobPhase(phase, message, output) {
|
|
91
|
+
queue.push({
|
|
92
|
+
...nextEnvelope(),
|
|
93
|
+
log: {
|
|
94
|
+
level: "info",
|
|
95
|
+
message,
|
|
96
|
+
output: {
|
|
97
|
+
phase,
|
|
98
|
+
...output
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
type: "runner.job.phase"
|
|
102
|
+
});
|
|
103
|
+
},
|
|
90
104
|
recordRuntimeEvent,
|
|
91
105
|
recordSchemaValidationFailure(message, issues, workflowId) {
|
|
92
106
|
queue.push({
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
//#region src/runner-job/delivery.ts
|
|
3
|
+
const GITHUB_HTTPS_REPOSITORY_RE = /^https:\/\/github\.com\/([^/]+\/[^/.]+)(?:\.git)?$/;
|
|
4
|
+
const GITHUB_SSH_REPOSITORY_RE = /^git@github\.com:([^/]+\/[^/.]+)(?:\.git)?$/;
|
|
5
|
+
const createPullRequest = async (options) => {
|
|
6
|
+
if (!options.payload.delivery.pullRequest) return null;
|
|
7
|
+
const repository = options.payload.repository;
|
|
8
|
+
const env = compactEnv(options.env);
|
|
9
|
+
const runCommand = options.runCommand ?? runDeliveryCommand;
|
|
10
|
+
const branch = (await runCommand("git", ["branch", "--show-current"], {
|
|
11
|
+
cwd: options.worktreePath,
|
|
12
|
+
env,
|
|
13
|
+
stdin: "ignore"
|
|
14
|
+
})).stdout.trim();
|
|
15
|
+
if (!branch) throw new Error("Pull request delivery requires a checked-out branch");
|
|
16
|
+
const headOwner = options.env.PIPELINE_PR_HEAD_OWNER?.trim() || "oisin-bot";
|
|
17
|
+
const repositoryName = githubRepositoryName(repository.url);
|
|
18
|
+
await runCommand("git", [
|
|
19
|
+
"push",
|
|
20
|
+
"--set-upstream",
|
|
21
|
+
"origin",
|
|
22
|
+
branch
|
|
23
|
+
], {
|
|
24
|
+
cwd: options.worktreePath,
|
|
25
|
+
env,
|
|
26
|
+
stdin: "ignore"
|
|
27
|
+
});
|
|
28
|
+
const url = (await runCommand("gh", [
|
|
29
|
+
"pr",
|
|
30
|
+
"create",
|
|
31
|
+
"--fill",
|
|
32
|
+
"--base",
|
|
33
|
+
repository.baseBranch,
|
|
34
|
+
"--head",
|
|
35
|
+
`${headOwner}:${branch}`,
|
|
36
|
+
"--repo",
|
|
37
|
+
repositoryName
|
|
38
|
+
], {
|
|
39
|
+
cwd: options.worktreePath,
|
|
40
|
+
env,
|
|
41
|
+
stdin: "ignore"
|
|
42
|
+
})).stdout.trim();
|
|
43
|
+
return url ? { url } : null;
|
|
44
|
+
};
|
|
45
|
+
const runDeliveryCommand = (command, args, options) => execa(command, args, options);
|
|
46
|
+
function compactEnv(env) {
|
|
47
|
+
return Object.fromEntries(Object.entries(env).filter((entry) => Boolean(entry[1])));
|
|
48
|
+
}
|
|
49
|
+
function githubRepositoryName(repositoryUrl) {
|
|
50
|
+
const httpsMatch = repositoryUrl.match(GITHUB_HTTPS_REPOSITORY_RE);
|
|
51
|
+
if (httpsMatch?.[1]) return httpsMatch[1];
|
|
52
|
+
const sshMatch = repositoryUrl.match(GITHUB_SSH_REPOSITORY_RE);
|
|
53
|
+
if (sshMatch?.[1]) return sshMatch[1];
|
|
54
|
+
throw new Error("Pull request delivery requires a GitHub repository URL");
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { createPullRequest };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { loadPipelineConfig } from "../config.js";
|
|
2
|
+
import { execa } from "execa";
|
|
3
|
+
//#region src/runner-job/devspace.ts
|
|
4
|
+
function assertRunnerDevspaceReady(worktreePath) {
|
|
5
|
+
return { config: loadPipelineConfig(worktreePath, { allowMissingLintFileReferences: true }) };
|
|
6
|
+
}
|
|
7
|
+
async function runRunnerDevspaceSmoke(options) {
|
|
8
|
+
const smoke = options.config.runner_job.environment.smoke;
|
|
9
|
+
if (smoke.length === 0) return "skipped";
|
|
10
|
+
const runCommand = options.runCommand ?? runDevspaceCommand;
|
|
11
|
+
for (const command of smoke) await runCommand(command.command, command.args, {
|
|
12
|
+
cwd: options.worktreePath,
|
|
13
|
+
env: compactEnv(options.env),
|
|
14
|
+
stdin: "ignore"
|
|
15
|
+
});
|
|
16
|
+
return "ran";
|
|
17
|
+
}
|
|
18
|
+
async function runRunnerEnvironmentSetup(options) {
|
|
19
|
+
const setup = options.config.runner_job.environment.setup;
|
|
20
|
+
if (setup.length === 0) return "skipped";
|
|
21
|
+
const runCommand = options.runCommand ?? runDevspaceCommand;
|
|
22
|
+
for (const command of setup) await runCommand(command.command, command.args, {
|
|
23
|
+
cwd: options.worktreePath,
|
|
24
|
+
env: compactEnv(options.env),
|
|
25
|
+
stdin: "ignore"
|
|
26
|
+
});
|
|
27
|
+
return "ran";
|
|
28
|
+
}
|
|
29
|
+
const runDevspaceCommand = (command, args, options) => execa(command, args, options);
|
|
30
|
+
function compactEnv(env) {
|
|
31
|
+
return Object.fromEntries(Object.entries(env).filter((entry) => Boolean(entry[1])));
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
export { assertRunnerDevspaceReady, runRunnerDevspaceSmoke, runRunnerEnvironmentSetup };
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { PipelineConfigError } from "../config.js";
|
|
2
|
+
import { runPipelineFromConfig } from "../pipeline-runtime.js";
|
|
3
|
+
import { RUNNER_PAYLOAD_ENV, parseRunnerJobPayloadWithIssues } from "../runner-job-contract.js";
|
|
4
|
+
import { createRunnerEventSink } from "../runner-event-sink.js";
|
|
5
|
+
import { compileScheduleArtifact, generateScheduleArtifact } from "../schedule-planner.js";
|
|
6
|
+
import { createPullRequest } from "./delivery.js";
|
|
7
|
+
import { assertRunnerDevspaceReady, runRunnerDevspaceSmoke, runRunnerEnvironmentSetup } from "./devspace.js";
|
|
8
|
+
import { prepareRunnerWorkspace } from "./workspace.js";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
//#region src/runner-job/run.ts
|
|
12
|
+
const EXIT_PASS = 0;
|
|
13
|
+
const EXIT_FAIL = 1;
|
|
14
|
+
const EXIT_CANCELLED = 130;
|
|
15
|
+
const EXIT_VALIDATION = 64;
|
|
16
|
+
const EXIT_STARTUP = 70;
|
|
17
|
+
const RUNNER_SCHEDULE_ENTRYPOINT = "pipe";
|
|
18
|
+
async function runRunnerJob(options = {}) {
|
|
19
|
+
const prepared = prepareRunnerJob(options);
|
|
20
|
+
if (prepared.exitCode !== void 0) return prepared.exitCode;
|
|
21
|
+
if ("validationFailure" in prepared) return reportPreparedValidationFailure(prepared.validationFailure, options);
|
|
22
|
+
const { env, payload, stderr } = prepared.job;
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const signalEmitter = options.signalEmitter ?? process;
|
|
25
|
+
const forceExit = options.onForceExit ?? ((exitCode) => process.exit(exitCode));
|
|
26
|
+
let signalExitCode;
|
|
27
|
+
let signalCount = 0;
|
|
28
|
+
let signalFinalResultRecorded = false;
|
|
29
|
+
const sink = createRunnerSink({
|
|
30
|
+
env,
|
|
31
|
+
events: payload.events,
|
|
32
|
+
fetch: options.fetch,
|
|
33
|
+
runId: payload.run.id
|
|
34
|
+
});
|
|
35
|
+
const handleSignal = (exitCode) => {
|
|
36
|
+
signalCount += 1;
|
|
37
|
+
if (signalCount === 1) {
|
|
38
|
+
signalExitCode = exitCode;
|
|
39
|
+
sink.recordCancellation(RUNNER_SCHEDULE_ENTRYPOINT);
|
|
40
|
+
signalFinalResultRecorded = true;
|
|
41
|
+
controller.abort();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
forceExit(signalExitCode ?? exitCode);
|
|
45
|
+
};
|
|
46
|
+
const handleSigterm = () => handleSignal(EXIT_CANCELLED);
|
|
47
|
+
const handleSigint = () => handleSignal(EXIT_CANCELLED);
|
|
48
|
+
signalEmitter.on("SIGTERM", handleSigterm);
|
|
49
|
+
signalEmitter.on("SIGINT", handleSigint);
|
|
50
|
+
const runner = options.pipelineRunner ?? runPipelineFromConfig;
|
|
51
|
+
let sawWorkflowFinish = false;
|
|
52
|
+
try {
|
|
53
|
+
const { config, readiness, task, workflowId, workspace } = await prepareReadyWorkspace(options, payload, env, sink);
|
|
54
|
+
const result = await runner({
|
|
55
|
+
config,
|
|
56
|
+
reporter: (event) => {
|
|
57
|
+
if (event.type === "workflow.finish") sawWorkflowFinish = true;
|
|
58
|
+
sink.recordRuntimeEvent(event);
|
|
59
|
+
},
|
|
60
|
+
runId: payload.run.id,
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
task,
|
|
63
|
+
hookPolicy: { allowCommandHooks: true },
|
|
64
|
+
workflowId,
|
|
65
|
+
worktreePath: workspace.worktreePath
|
|
66
|
+
});
|
|
67
|
+
if (result.outcome === "PASS") await deliverSuccessfulRun(options, payload, workspace, readiness, sink);
|
|
68
|
+
if (!(sawWorkflowFinish || signalFinalResultRecorded)) sink.recordFinalResult(result.outcome, result.plan.workflowId);
|
|
69
|
+
if (await flushAndReport(sink.flush, stderr) && !signalExitCode) return EXIT_STARTUP;
|
|
70
|
+
return signalExitCode ?? exitCodeForRuntimeResult(result);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
73
|
+
stderr.write(`${message}\n`);
|
|
74
|
+
await flushAndReport(sink.flush, stderr);
|
|
75
|
+
if (err instanceof PipelineConfigError) return signalExitCode ?? EXIT_VALIDATION;
|
|
76
|
+
return signalExitCode ?? EXIT_STARTUP;
|
|
77
|
+
} finally {
|
|
78
|
+
removeSignalListener(signalEmitter, "SIGTERM", handleSigterm);
|
|
79
|
+
removeSignalListener(signalEmitter, "SIGINT", handleSigint);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function prepareReadyWorkspace(options, payload, env, sink) {
|
|
83
|
+
const workspace = await prepareWorkspace(options, payload, env);
|
|
84
|
+
sink.recordRunnerJobPhase("workspace.prepared", "runner workspace prepared", { worktreePath: workspace.worktreePath });
|
|
85
|
+
const readiness = assertRunnerDevspaceReady(workspace.worktreePath);
|
|
86
|
+
const config = requireRunnerConfig(readiness);
|
|
87
|
+
sink.recordRunnerJobPhase("environment.ready", "runner environment ready");
|
|
88
|
+
const setupStatus = await runRunnerEnvironmentSetup({
|
|
89
|
+
config,
|
|
90
|
+
env: workspace.env,
|
|
91
|
+
runCommand: options.runDevspaceCommand,
|
|
92
|
+
worktreePath: workspace.worktreePath
|
|
93
|
+
});
|
|
94
|
+
sink.recordRunnerJobPhase(`environment.setup.${setupStatus}`, `runner environment setup ${setupStatus}`);
|
|
95
|
+
const task = resolveRunnerTask(payload.task, workspace.worktreePath);
|
|
96
|
+
const compiled = await (options.prepareSchedule ?? (options.pipelineRunner ? useConfiguredDefaultWorkflow : generateRunnerSchedule))(config, task, workspace, sink);
|
|
97
|
+
return {
|
|
98
|
+
config: compiled.config,
|
|
99
|
+
readiness,
|
|
100
|
+
task,
|
|
101
|
+
workflowId: compiled.workflowId,
|
|
102
|
+
workspace
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function requireRunnerConfig(readiness) {
|
|
106
|
+
if (!readiness.config) throw new PipelineConfigError("PIPELINE_CONFIG_VALIDATION_ERROR", "Runner jobs require a repository pipeline config", [{
|
|
107
|
+
message: ".pipeline/pipeline.yaml is required for runner jobs",
|
|
108
|
+
path: ".pipeline/pipeline.yaml"
|
|
109
|
+
}]);
|
|
110
|
+
return readiness.config;
|
|
111
|
+
}
|
|
112
|
+
function resolveRunnerTask(task, worktreePath) {
|
|
113
|
+
if (task.kind === "prompt") return task.prompt;
|
|
114
|
+
if (task.path) return readFileSync(join(worktreePath, task.path), "utf8");
|
|
115
|
+
return task.id;
|
|
116
|
+
}
|
|
117
|
+
function useConfiguredDefaultWorkflow(config) {
|
|
118
|
+
return Promise.resolve({
|
|
119
|
+
config,
|
|
120
|
+
workflowId: config.default_workflow
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
async function generateRunnerSchedule(config, task, workspace, sink) {
|
|
124
|
+
const result = await generateScheduleArtifact({
|
|
125
|
+
config,
|
|
126
|
+
entrypointId: RUNNER_SCHEDULE_ENTRYPOINT,
|
|
127
|
+
task,
|
|
128
|
+
worktreePath: workspace.worktreePath
|
|
129
|
+
});
|
|
130
|
+
sink.recordRunnerJobPhase("schedule.generated", "runner schedule generated", { path: result.path });
|
|
131
|
+
const compiled = compileScheduleArtifact(config, result.artifact, workspace.worktreePath);
|
|
132
|
+
return {
|
|
133
|
+
config: compiled.config,
|
|
134
|
+
workflowId: compiled.workflowId
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function deliverSuccessfulRun(options, payload, workspace, readiness, sink) {
|
|
138
|
+
if (readiness.config) {
|
|
139
|
+
const config = readiness.config;
|
|
140
|
+
const smokeStatus = await runRunnerDevspaceSmokeWithPhase(options, config, workspace, sink);
|
|
141
|
+
sink.recordRunnerJobPhase(`environment.smoke.${smokeStatus}`, `runner environment smoke ${smokeStatus}`);
|
|
142
|
+
}
|
|
143
|
+
const pullRequest = await createRunnerPullRequestWithPhase(options, payload, workspace, sink);
|
|
144
|
+
if (pullRequest) sink.recordRunnerJobPhase("delivery.pull_request", "runner pull request created", { url: pullRequest.url });
|
|
145
|
+
}
|
|
146
|
+
async function runRunnerDevspaceSmokeWithPhase(options, config, workspace, sink) {
|
|
147
|
+
try {
|
|
148
|
+
return await runRunnerDevspaceSmoke({
|
|
149
|
+
config,
|
|
150
|
+
env: workspace.env,
|
|
151
|
+
runCommand: options.runDevspaceCommand,
|
|
152
|
+
worktreePath: workspace.worktreePath
|
|
153
|
+
});
|
|
154
|
+
} catch (err) {
|
|
155
|
+
sink.recordRunnerJobPhase("environment.smoke.failed", "runner environment smoke failed", { error: errorMessage(err) });
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function createRunnerPullRequestWithPhase(options, payload, workspace, sink) {
|
|
160
|
+
try {
|
|
161
|
+
return await createRunnerPullRequest(options, payload, workspace.worktreePath, workspace.env);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
sink.recordRunnerJobPhase("delivery.pull_request.failed", "runner pull request creation failed", { error: errorMessage(err) });
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function createRunnerPullRequest(options, payload, worktreePath, env) {
|
|
168
|
+
return (options.createPullRequest ?? createPullRequest)({
|
|
169
|
+
env,
|
|
170
|
+
payload,
|
|
171
|
+
worktreePath
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function prepareWorkspace(options, payload, env) {
|
|
175
|
+
return (options.prepareWorkspace ?? prepareRunnerWorkspace)({
|
|
176
|
+
cwd: options.cwd,
|
|
177
|
+
env,
|
|
178
|
+
payload
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function prepareRunnerJob(options) {
|
|
182
|
+
const env = options.env ?? process.env;
|
|
183
|
+
const stderr = options.stderr ?? process.stderr;
|
|
184
|
+
const payloadRaw = env[RUNNER_PAYLOAD_ENV];
|
|
185
|
+
if (!payloadRaw) {
|
|
186
|
+
stderr.write(`${RUNNER_PAYLOAD_ENV} is required\n`);
|
|
187
|
+
return { exitCode: EXIT_VALIDATION };
|
|
188
|
+
}
|
|
189
|
+
const payload = parsePayload(payloadRaw, stderr);
|
|
190
|
+
if (!payload.ok) return { validationFailure: {
|
|
191
|
+
env,
|
|
192
|
+
error: payload.error,
|
|
193
|
+
recoverable: payload.recoverable,
|
|
194
|
+
stderr
|
|
195
|
+
} };
|
|
196
|
+
return { job: {
|
|
197
|
+
env,
|
|
198
|
+
payload: payload.payload,
|
|
199
|
+
stderr
|
|
200
|
+
} };
|
|
201
|
+
}
|
|
202
|
+
function parsePayload(payloadRaw, stderr) {
|
|
203
|
+
const result = parseRunnerJobPayloadWithIssues(payloadRaw);
|
|
204
|
+
if (!result.ok) stderr.write(`${result.error.message}\n`);
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
async function reportPreparedValidationFailure(failure, options) {
|
|
208
|
+
if (failure.recoverable) {
|
|
209
|
+
const sink = createRunnerSink({
|
|
210
|
+
env: failure.env,
|
|
211
|
+
events: failure.recoverable.events,
|
|
212
|
+
fetch: options.fetch,
|
|
213
|
+
runId: failure.recoverable.run.id
|
|
214
|
+
});
|
|
215
|
+
sink.recordSchemaValidationFailure(failure.error.message, failure.error.issues, RUNNER_SCHEDULE_ENTRYPOINT);
|
|
216
|
+
await flushAndReport(sink.flush, failure.stderr);
|
|
217
|
+
}
|
|
218
|
+
return EXIT_VALIDATION;
|
|
219
|
+
}
|
|
220
|
+
function resolveAuthToken(authTokenEnv, env, stderr) {
|
|
221
|
+
const token = env[authTokenEnv]?.trim();
|
|
222
|
+
if (!token) {
|
|
223
|
+
stderr.write(`Runner event auth token is required. Set ${authTokenEnv}.\n`);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return token;
|
|
227
|
+
}
|
|
228
|
+
function createRunnerSink(options) {
|
|
229
|
+
const authToken = resolveAuthToken(options.events.authTokenEnv, options.env, { write: () => true });
|
|
230
|
+
if (!authToken) return createNoopRunnerEventSink();
|
|
231
|
+
return createRunnerEventSink({
|
|
232
|
+
authHeader: options.events.authHeader,
|
|
233
|
+
authToken,
|
|
234
|
+
fetch: options.fetch,
|
|
235
|
+
runId: options.runId,
|
|
236
|
+
url: options.events.url
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function createNoopRunnerEventSink() {
|
|
240
|
+
return {
|
|
241
|
+
fail: () => Promise.resolve(),
|
|
242
|
+
flush: () => Promise.resolve(),
|
|
243
|
+
recordCancellation: () => void 0,
|
|
244
|
+
recordFinalResult: () => void 0,
|
|
245
|
+
recordRunnerJobPhase: () => void 0,
|
|
246
|
+
recordRuntimeEvent: () => void 0,
|
|
247
|
+
recordSchemaValidationFailure: () => void 0
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function errorMessage(err) {
|
|
251
|
+
return err instanceof Error ? err.message : String(err);
|
|
252
|
+
}
|
|
253
|
+
function exitCodeForRuntimeResult(result) {
|
|
254
|
+
switch (result.outcome) {
|
|
255
|
+
case "PASS": return EXIT_PASS;
|
|
256
|
+
case "FAIL": return EXIT_FAIL;
|
|
257
|
+
case "CANCELLED": return EXIT_CANCELLED;
|
|
258
|
+
default: return EXIT_STARTUP;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function flushAndReport(flush, stderr) {
|
|
262
|
+
try {
|
|
263
|
+
await flush();
|
|
264
|
+
return false;
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
267
|
+
stderr.write(`Event sink flush failed: ${message}\n`);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function removeSignalListener(emitter, event, listener) {
|
|
272
|
+
if (emitter.off) {
|
|
273
|
+
emitter.off(event, listener);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
emitter.removeListener?.(event, listener);
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
export { runRunnerJob };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { simpleGit } from "simple-git";
|
|
2
|
+
//#region src/runner-job/workspace.ts
|
|
3
|
+
const RUNNER_JOB_WORKSPACE_PATH = "/workspace";
|
|
4
|
+
async function prepareRunnerWorkspace(options) {
|
|
5
|
+
const existingWorktreePath = options.env.PIPELINE_TARGET_PATH ?? options.cwd;
|
|
6
|
+
if (existingWorktreePath) return {
|
|
7
|
+
env: {
|
|
8
|
+
...options.env,
|
|
9
|
+
PIPELINE_TARGET_PATH: existingWorktreePath
|
|
10
|
+
},
|
|
11
|
+
worktreePath: existingWorktreePath
|
|
12
|
+
};
|
|
13
|
+
const repository = options.payload.repository;
|
|
14
|
+
if (!repository) throw new RunnerWorkspaceError("repository is required for runner jobs");
|
|
15
|
+
const git = options.createGitClient?.() ?? simpleGit();
|
|
16
|
+
const repositoryUrl = repository.url;
|
|
17
|
+
const branchName = runnerBranchName(options.payload);
|
|
18
|
+
try {
|
|
19
|
+
await git.clone(repositoryUrl, RUNNER_JOB_WORKSPACE_PATH, ["--no-tags"]);
|
|
20
|
+
await git.cwd(RUNNER_JOB_WORKSPACE_PATH).checkoutBranch(branchName, repository.sha ?? `origin/${repository.baseBranch}`);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
throw new RunnerWorkspaceError(redactSecretText(errorMessage(err)));
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
env: {
|
|
26
|
+
...options.env,
|
|
27
|
+
PIPELINE_TARGET_PATH: RUNNER_JOB_WORKSPACE_PATH
|
|
28
|
+
},
|
|
29
|
+
worktreePath: RUNNER_JOB_WORKSPACE_PATH
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
var RunnerWorkspaceError = class extends Error {
|
|
33
|
+
constructor(message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "RunnerWorkspaceError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
function errorMessage(err) {
|
|
39
|
+
return err instanceof Error ? err.message : String(err);
|
|
40
|
+
}
|
|
41
|
+
const CREDENTIAL_IN_URL_RE = /(https?:\/\/)([^:@\s/]+):([^@\s/]+)@/g;
|
|
42
|
+
function redactSecretText(value) {
|
|
43
|
+
return value.replace(CREDENTIAL_IN_URL_RE, "$1$2:<redacted>@");
|
|
44
|
+
}
|
|
45
|
+
function runnerBranchName(payload) {
|
|
46
|
+
const runId = payload.run?.id;
|
|
47
|
+
const source = payload.task?.kind === "ticket" ? payload.task.id : runId;
|
|
48
|
+
if (!(source && runId)) throw new RunnerWorkspaceError("run and task context are required for runner jobs");
|
|
49
|
+
return `pipeline/${source.trim().toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").replace(/^[./-]+|[./-]+$/g, "").replace(/\.{2,}/g, ".") || runId}`;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { prepareRunnerWorkspace };
|