@redwoodjs/agent-ci 0.7.1 → 0.8.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/dist/cli.js +414 -298
- package/dist/commit-status.js +1 -1
- package/dist/config.js +42 -15
- package/dist/config.test.js +157 -0
- package/dist/docker/container-config.js +7 -5
- package/dist/docker/container-config.test.js +45 -2
- package/dist/docker/docker-socket.js +119 -0
- package/dist/docker/docker-socket.test.js +117 -0
- package/dist/output/cleanup.js +42 -6
- package/dist/output/cleanup.test.js +15 -0
- package/dist/runner/directory-setup.js +2 -3
- package/dist/runner/local-job.js +51 -19
- package/dist/runner/local-job.test.js +43 -0
- package/dist/runner/result-builder.js +2 -1
- package/dist/runner/workspace.js +3 -2
- package/dist/workflow/remote-workflow-fetch.js +131 -0
- package/dist/workflow/remote-workflow-fetch.test.js +233 -0
- package/dist/workflow/reusable-workflow.js +134 -0
- package/dist/workflow/reusable-workflow.test.js +655 -0
- package/dist/workflow/workflow-parser.js +33 -20
- package/dist/workflow/workflow-parser.test.js +95 -2
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import fs from "fs";
|
|
5
|
-
import { config, loadMachineSecrets } from "./config.js";
|
|
5
|
+
import { config, loadMachineSecrets, resolveRepoSlug } from "./config.js";
|
|
6
6
|
import { getNextLogNum } from "./output/logger.js";
|
|
7
7
|
import { setWorkingDirectory, DEFAULT_WORKING_DIR, PROJECT_ROOT, } from "./output/working-directory.js";
|
|
8
8
|
import { debugCli } from "./output/debug.js";
|
|
9
9
|
import { executeLocalJob } from "./runner/local-job.js";
|
|
10
|
-
import {
|
|
10
|
+
import { parseWorkflowSteps, parseWorkflowServices, parseWorkflowContainer, validateSecrets, parseMatrixDef, expandMatrixCombinations, collapseMatrixToSingle, isWorkflowRelevant, getChangedFiles, parseJobOutputDefs, parseJobIf, evaluateJobIf, parseFailFast, expandExpressions, } from "./workflow/workflow-parser.js";
|
|
11
11
|
import { resolveJobOutputs } from "./runner/result-builder.js";
|
|
12
12
|
import { createConcurrencyLimiter, getDefaultMaxConcurrentJobs } from "./output/concurrency.js";
|
|
13
13
|
import { isWarmNodeModules, computeLockfileHash } from "./output/cleanup.js";
|
|
14
14
|
import { getWorkingDirectory } from "./output/working-directory.js";
|
|
15
15
|
import { pruneOrphanedDockerResources } from "./docker/shutdown.js";
|
|
16
|
-
import {
|
|
16
|
+
import { topoSort } from "./workflow/job-scheduler.js";
|
|
17
|
+
import { expandReusableJobs } from "./workflow/reusable-workflow.js";
|
|
18
|
+
import { prefetchRemoteWorkflows } from "./workflow/remote-workflow-fetch.js";
|
|
17
19
|
import { printSummary } from "./output/reporter.js";
|
|
18
20
|
import { syncWorkspaceForRetry } from "./runner/sync.js";
|
|
19
21
|
import { RunStateStore } from "./output/run-state.js";
|
|
@@ -351,7 +353,8 @@ async function runWorkflows(options) {
|
|
|
351
353
|
// Multiple workflows (--all mode)
|
|
352
354
|
// Determine warm-cache status from the first workflow's repo root
|
|
353
355
|
const firstRepoRoot = resolveRepoRootFromWorkflow(workflowPaths[0]);
|
|
354
|
-
|
|
356
|
+
config.GITHUB_REPO ?? (config.GITHUB_REPO = resolveRepoSlug(firstRepoRoot));
|
|
357
|
+
const repoSlug = config.GITHUB_REPO.replace("/", "-");
|
|
355
358
|
let lockfileHash = "no-lockfile";
|
|
356
359
|
try {
|
|
357
360
|
lockfileHash = computeLockfileHash(firstRepoRoot);
|
|
@@ -437,307 +440,424 @@ async function runWorkflows(options) {
|
|
|
437
440
|
async function handleWorkflow(options) {
|
|
438
441
|
const { sha, pauseOnFailure, noMatrix = false, store } = options;
|
|
439
442
|
let workflowPath = options.workflowPath;
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
443
|
+
if (!fs.existsSync(workflowPath)) {
|
|
444
|
+
throw new Error(`Workflow file not found: ${workflowPath}`);
|
|
445
|
+
}
|
|
446
|
+
const repoRoot = resolveRepoRootFromWorkflow(workflowPath);
|
|
447
|
+
if (!process.env.AGENT_CI_WORKING_DIR) {
|
|
448
|
+
setWorkingDirectory(DEFAULT_WORKING_DIR);
|
|
449
|
+
}
|
|
450
|
+
const { headSha, shaRef } = sha
|
|
451
|
+
? resolveHeadSha(repoRoot, sha)
|
|
452
|
+
: { headSha: undefined, shaRef: undefined };
|
|
453
|
+
// Always resolve the real HEAD SHA for the push event context (before/after).
|
|
454
|
+
// This is separate from headSha which may be undefined for dirty workspace copies.
|
|
455
|
+
const realHeadSha = headSha ?? resolveHeadSha(repoRoot, "HEAD").headSha;
|
|
456
|
+
const baseSha = resolveBaseSha(repoRoot, realHeadSha);
|
|
457
|
+
const githubRepo = config.GITHUB_REPO ?? resolveRepoSlug(repoRoot);
|
|
458
|
+
config.GITHUB_REPO = githubRepo;
|
|
459
|
+
const [owner, name] = githubRepo.split("/");
|
|
460
|
+
const remoteCacheDir = path.resolve(getWorkingDirectory(), "cache", "remote-workflows");
|
|
461
|
+
const remoteCache = await prefetchRemoteWorkflows(workflowPath, remoteCacheDir);
|
|
462
|
+
const expandedEntries = expandReusableJobs(workflowPath, repoRoot, remoteCache);
|
|
463
|
+
if (expandedEntries.length === 0) {
|
|
464
|
+
debugCli(`[Agent CI] No jobs found in workflow: ${path.basename(workflowPath)}`);
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
const expandedJobs = [];
|
|
468
|
+
for (const entry of expandedEntries) {
|
|
469
|
+
const matrixDef = await parseMatrixDef(entry.workflowPath, entry.sourceTaskName);
|
|
470
|
+
if (matrixDef) {
|
|
471
|
+
const combos = noMatrix
|
|
472
|
+
? collapseMatrixToSingle(matrixDef)
|
|
473
|
+
: expandMatrixCombinations(matrixDef);
|
|
474
|
+
const total = combos.length;
|
|
475
|
+
for (let ci = 0; ci < combos.length; ci++) {
|
|
476
|
+
expandedJobs.push({
|
|
477
|
+
workflowPath: entry.workflowPath,
|
|
478
|
+
taskName: entry.id,
|
|
479
|
+
sourceTaskName: entry.sourceTaskName,
|
|
480
|
+
matrixContext: noMatrix
|
|
481
|
+
? combos[ci]
|
|
482
|
+
: {
|
|
483
|
+
...combos[ci],
|
|
484
|
+
__job_total: String(total),
|
|
485
|
+
__job_index: String(ci),
|
|
486
|
+
},
|
|
487
|
+
inputs: entry.inputs,
|
|
488
|
+
inputDefaults: entry.inputDefaults,
|
|
489
|
+
workflowCallOutputDefs: entry.workflowCallOutputDefs,
|
|
490
|
+
callerJobId: entry.callerJobId,
|
|
491
|
+
});
|
|
481
492
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
},
|
|
510
|
-
steps,
|
|
511
|
-
services,
|
|
512
|
-
container: container ?? undefined,
|
|
513
|
-
workflowPath,
|
|
514
|
-
taskId: ej.taskName,
|
|
515
|
-
};
|
|
516
|
-
const result = await executeLocalJob(job, { pauseOnFailure, store });
|
|
517
|
-
return [result];
|
|
518
|
-
}
|
|
519
|
-
// ── Multi-job orchestration ────────────────────────────────────────────────
|
|
520
|
-
const maxJobs = getDefaultMaxConcurrentJobs();
|
|
521
|
-
// ── Warm-cache check ───────────────────────────────────────────────────────
|
|
522
|
-
const repoSlug = githubRepo.replace("/", "-");
|
|
523
|
-
let lockfileHash = "no-lockfile";
|
|
524
|
-
try {
|
|
525
|
-
lockfileHash = computeLockfileHash(repoRoot);
|
|
526
|
-
}
|
|
527
|
-
catch { }
|
|
528
|
-
const warmModulesDir = path.resolve(getWorkingDirectory(), "cache", "warm-modules", repoSlug, lockfileHash);
|
|
529
|
-
let warm = isWarmNodeModules(warmModulesDir);
|
|
530
|
-
// Naming convention: agent-ci-<N>[-j<idx>][-m<shardIdx>]
|
|
531
|
-
const baseRunNum = options.baseRunNum ?? getNextLogNum("agent-ci");
|
|
532
|
-
let globalIdx = 0;
|
|
533
|
-
const buildJob = (ej) => {
|
|
534
|
-
const secrets = loadMachineSecrets(repoRoot);
|
|
535
|
-
const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
|
|
536
|
-
validateSecrets(workflowPath, ej.taskName, secrets, secretsFilePath);
|
|
537
|
-
const idx = globalIdx++;
|
|
538
|
-
let suffix = `-j${idx + 1}`;
|
|
539
|
-
if (ej.matrixContext) {
|
|
540
|
-
const shardIdx = parseInt(ej.matrixContext.__job_index ?? "0", 10) + 1;
|
|
541
|
-
suffix += `-m${shardIdx}`;
|
|
542
|
-
}
|
|
543
|
-
const derivedRunnerName = `agent-ci-${baseRunNum}${suffix}`;
|
|
544
|
-
return {
|
|
545
|
-
deliveryId: `run-${Date.now()}`,
|
|
546
|
-
eventType: "workflow_job",
|
|
547
|
-
githubJobId: Math.floor(Math.random() * 1000000).toString(),
|
|
548
|
-
githubRepo: githubRepo,
|
|
549
|
-
githubToken: "mock_token",
|
|
550
|
-
headSha: headSha,
|
|
551
|
-
shaRef: shaRef,
|
|
552
|
-
env: { AGENT_CI_LOCAL: "true" },
|
|
553
|
-
repository: {
|
|
554
|
-
name: name,
|
|
555
|
-
full_name: githubRepo,
|
|
556
|
-
owner: { login: owner },
|
|
557
|
-
default_branch: "main",
|
|
558
|
-
},
|
|
559
|
-
runnerName: derivedRunnerName,
|
|
560
|
-
steps: undefined,
|
|
561
|
-
services: undefined,
|
|
562
|
-
container: undefined,
|
|
563
|
-
workflowPath,
|
|
564
|
-
taskId: ej.taskName,
|
|
565
|
-
};
|
|
566
|
-
};
|
|
567
|
-
const runJob = async (ej, needsContext) => {
|
|
568
|
-
const { taskName, matrixContext } = ej;
|
|
569
|
-
debugCli(`Running: ${path.basename(workflowPath)} | Task: ${taskName}${matrixContext ? ` | Matrix: ${JSON.stringify(Object.fromEntries(Object.entries(matrixContext).filter(([k]) => !k.startsWith("__"))))}` : ""}`);
|
|
570
|
-
const secrets = loadMachineSecrets(repoRoot);
|
|
571
|
-
const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
|
|
572
|
-
validateSecrets(workflowPath, taskName, secrets, secretsFilePath);
|
|
573
|
-
const steps = await parseWorkflowSteps(workflowPath, taskName, secrets, matrixContext, needsContext);
|
|
574
|
-
const services = await parseWorkflowServices(workflowPath, taskName);
|
|
575
|
-
const container = await parseWorkflowContainer(workflowPath, taskName);
|
|
576
|
-
const job = buildJob(ej);
|
|
577
|
-
job.steps = steps;
|
|
578
|
-
job.services = services;
|
|
579
|
-
job.container = container ?? undefined;
|
|
580
|
-
const result = await executeLocalJob(job, { pauseOnFailure, store });
|
|
581
|
-
// result.outputs now contains raw step outputs (extracted inside executeLocalJob
|
|
582
|
-
// before workspace cleanup). Resolve them to job-level outputs using the
|
|
583
|
-
// output definitions from the workflow YAML.
|
|
584
|
-
if (result.outputs && Object.keys(result.outputs).length > 0) {
|
|
585
|
-
const outputDefs = parseJobOutputDefs(workflowPath, taskName);
|
|
586
|
-
if (Object.keys(outputDefs).length > 0) {
|
|
587
|
-
result.outputs = resolveJobOutputs(outputDefs, result.outputs);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
expandedJobs.push({
|
|
496
|
+
workflowPath: entry.workflowPath,
|
|
497
|
+
taskName: entry.id,
|
|
498
|
+
sourceTaskName: entry.sourceTaskName,
|
|
499
|
+
inputs: entry.inputs,
|
|
500
|
+
inputDefaults: entry.inputDefaults,
|
|
501
|
+
workflowCallOutputDefs: entry.workflowCallOutputDefs,
|
|
502
|
+
callerJobId: entry.callerJobId,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// For single-job workflows, run directly without extra orchestration
|
|
507
|
+
if (expandedJobs.length === 1) {
|
|
508
|
+
const ej = expandedJobs[0];
|
|
509
|
+
const actualTaskName = ej.sourceTaskName ?? ej.taskName;
|
|
510
|
+
const secrets = loadMachineSecrets(repoRoot);
|
|
511
|
+
const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
|
|
512
|
+
validateSecrets(ej.workflowPath, actualTaskName, secrets, secretsFilePath);
|
|
513
|
+
// Resolve inputs for called workflow jobs
|
|
514
|
+
let inputsContext;
|
|
515
|
+
if (ej.callerJobId) {
|
|
516
|
+
inputsContext = { ...ej.inputDefaults };
|
|
517
|
+
if (ej.inputs) {
|
|
518
|
+
for (const [k, v] of Object.entries(ej.inputs)) {
|
|
519
|
+
inputsContext[k] = expandExpressions(v, repoRoot, secrets);
|
|
588
520
|
}
|
|
589
521
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
pruneOrphanedDockerResources();
|
|
593
|
-
const limiter = createConcurrencyLimiter(maxJobs);
|
|
594
|
-
const allResults = [];
|
|
595
|
-
// Accumulate job outputs across waves for needs.*.outputs.* resolution
|
|
596
|
-
const jobOutputs = new Map();
|
|
597
|
-
// ── Dependency-aware wave scheduling ──────────────────────────────────────
|
|
598
|
-
const deps = parseJobDependencies(workflowPath);
|
|
599
|
-
const waves = topoSort(deps);
|
|
600
|
-
const taskNamesInWf = new Set(expandedJobs.map((j) => j.taskName));
|
|
601
|
-
const filteredWaves = waves
|
|
602
|
-
.map((wave) => wave.filter((jobId) => taskNamesInWf.has(jobId)))
|
|
603
|
-
.filter((wave) => wave.length > 0);
|
|
604
|
-
if (filteredWaves.length === 0) {
|
|
605
|
-
filteredWaves.push(Array.from(taskNamesInWf));
|
|
606
|
-
}
|
|
607
|
-
/** Build a needsContext for a job from its dependencies' accumulated outputs */
|
|
608
|
-
const buildNeedsContext = (jobId) => {
|
|
609
|
-
const jobDeps = deps.get(jobId);
|
|
610
|
-
if (!jobDeps || jobDeps.length === 0) {
|
|
611
|
-
return undefined;
|
|
612
|
-
}
|
|
613
|
-
const ctx = {};
|
|
614
|
-
for (const depId of jobDeps) {
|
|
615
|
-
ctx[depId] = jobOutputs.get(depId) ?? {};
|
|
522
|
+
if (Object.keys(inputsContext).length === 0) {
|
|
523
|
+
inputsContext = undefined;
|
|
616
524
|
}
|
|
617
|
-
|
|
525
|
+
}
|
|
526
|
+
const steps = await parseWorkflowSteps(ej.workflowPath, actualTaskName, secrets, ej.matrixContext, undefined, inputsContext);
|
|
527
|
+
const services = await parseWorkflowServices(ej.workflowPath, actualTaskName);
|
|
528
|
+
const container = await parseWorkflowContainer(ej.workflowPath, actualTaskName);
|
|
529
|
+
const job = {
|
|
530
|
+
deliveryId: `run-${Date.now()}`,
|
|
531
|
+
eventType: "workflow_job",
|
|
532
|
+
githubJobId: `local-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
|
|
533
|
+
githubRepo: githubRepo,
|
|
534
|
+
githubToken: "mock_token",
|
|
535
|
+
headSha: headSha,
|
|
536
|
+
baseSha: baseSha,
|
|
537
|
+
realHeadSha: realHeadSha,
|
|
538
|
+
repoRoot: repoRoot,
|
|
539
|
+
shaRef: shaRef,
|
|
540
|
+
env: { AGENT_CI_LOCAL: "true" },
|
|
541
|
+
repository: {
|
|
542
|
+
name: name,
|
|
543
|
+
full_name: githubRepo,
|
|
544
|
+
owner: { login: owner },
|
|
545
|
+
default_branch: "main",
|
|
546
|
+
},
|
|
547
|
+
steps,
|
|
548
|
+
services,
|
|
549
|
+
container: container ?? undefined,
|
|
550
|
+
workflowPath: ej.workflowPath,
|
|
551
|
+
taskId: ej.taskName,
|
|
618
552
|
};
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
553
|
+
const result = await executeLocalJob(job, { pauseOnFailure, store });
|
|
554
|
+
return [result];
|
|
555
|
+
}
|
|
556
|
+
// ── Multi-job orchestration ────────────────────────────────────────────────
|
|
557
|
+
const maxJobs = getDefaultMaxConcurrentJobs();
|
|
558
|
+
// ── Warm-cache check ───────────────────────────────────────────────────────
|
|
559
|
+
const repoSlug = githubRepo.replace("/", "-");
|
|
560
|
+
let lockfileHash = "no-lockfile";
|
|
561
|
+
try {
|
|
562
|
+
lockfileHash = computeLockfileHash(repoRoot);
|
|
563
|
+
}
|
|
564
|
+
catch { }
|
|
565
|
+
const warmModulesDir = path.resolve(getWorkingDirectory(), "cache", "warm-modules", repoSlug, lockfileHash);
|
|
566
|
+
let warm = isWarmNodeModules(warmModulesDir);
|
|
567
|
+
// Naming convention: agent-ci-<N>[-j<idx>][-m<shardIdx>]
|
|
568
|
+
const baseRunNum = options.baseRunNum ?? getNextLogNum("agent-ci");
|
|
569
|
+
let globalIdx = 0;
|
|
570
|
+
const buildJob = (ej) => {
|
|
571
|
+
const actualTaskName = ej.sourceTaskName ?? ej.taskName;
|
|
572
|
+
const secrets = loadMachineSecrets(repoRoot);
|
|
573
|
+
const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
|
|
574
|
+
validateSecrets(ej.workflowPath, actualTaskName, secrets, secretsFilePath);
|
|
575
|
+
const idx = globalIdx++;
|
|
576
|
+
let suffix = `-j${idx + 1}`;
|
|
577
|
+
if (ej.matrixContext) {
|
|
578
|
+
const shardIdx = parseInt(ej.matrixContext.__job_index ?? "0", 10) + 1;
|
|
579
|
+
suffix += `-m${shardIdx}`;
|
|
580
|
+
}
|
|
581
|
+
const derivedRunnerName = `agent-ci-${baseRunNum}${suffix}`;
|
|
582
|
+
return {
|
|
583
|
+
deliveryId: `run-${Date.now()}`,
|
|
584
|
+
eventType: "workflow_job",
|
|
585
|
+
githubJobId: Math.floor(Math.random() * 1000000).toString(),
|
|
586
|
+
githubRepo: githubRepo,
|
|
587
|
+
githubToken: "mock_token",
|
|
588
|
+
headSha: headSha,
|
|
589
|
+
baseSha: baseSha,
|
|
590
|
+
realHeadSha: realHeadSha,
|
|
591
|
+
repoRoot: repoRoot,
|
|
592
|
+
shaRef: shaRef,
|
|
593
|
+
env: { AGENT_CI_LOCAL: "true" },
|
|
594
|
+
repository: {
|
|
595
|
+
name: name,
|
|
596
|
+
full_name: githubRepo,
|
|
597
|
+
owner: { login: owner },
|
|
598
|
+
default_branch: "main",
|
|
599
|
+
},
|
|
600
|
+
runnerName: derivedRunnerName,
|
|
601
|
+
steps: undefined,
|
|
602
|
+
services: undefined,
|
|
603
|
+
container: undefined,
|
|
604
|
+
workflowPath: ej.workflowPath,
|
|
605
|
+
taskId: ej.taskName,
|
|
624
606
|
};
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
607
|
+
};
|
|
608
|
+
// Cache resolved inputs per callerJobId (all sub-jobs share the same inputs)
|
|
609
|
+
const resolvedInputsCache = new Map();
|
|
610
|
+
const resolveInputsForJob = (ej, secrets, needsContext) => {
|
|
611
|
+
if (!ej.callerJobId) {
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
const cached = resolvedInputsCache.get(ej.callerJobId);
|
|
615
|
+
if (cached) {
|
|
616
|
+
return cached;
|
|
617
|
+
}
|
|
618
|
+
// Start with defaults, then override with caller's `with:` values (expanded)
|
|
619
|
+
const resolved = { ...ej.inputDefaults };
|
|
620
|
+
if (ej.inputs) {
|
|
621
|
+
for (const [k, v] of Object.entries(ej.inputs)) {
|
|
622
|
+
resolved[k] = expandExpressions(v, repoRoot, secrets, undefined, needsContext);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
resolvedInputsCache.set(ej.callerJobId, resolved);
|
|
626
|
+
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
|
627
|
+
};
|
|
628
|
+
const runJob = async (ej, needsContext) => {
|
|
629
|
+
const { taskName, matrixContext } = ej;
|
|
630
|
+
const actualTaskName = ej.sourceTaskName ?? taskName;
|
|
631
|
+
debugCli(`Running: ${path.basename(ej.workflowPath)} | Task: ${taskName}${matrixContext ? ` | Matrix: ${JSON.stringify(Object.fromEntries(Object.entries(matrixContext).filter(([k]) => !k.startsWith("__"))))}` : ""}`);
|
|
632
|
+
const secrets = loadMachineSecrets(repoRoot);
|
|
633
|
+
const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
|
|
634
|
+
validateSecrets(ej.workflowPath, actualTaskName, secrets, secretsFilePath);
|
|
635
|
+
const inputsContext = resolveInputsForJob(ej, secrets, needsContext);
|
|
636
|
+
const steps = await parseWorkflowSteps(ej.workflowPath, actualTaskName, secrets, matrixContext, needsContext, inputsContext);
|
|
637
|
+
const services = await parseWorkflowServices(ej.workflowPath, actualTaskName);
|
|
638
|
+
const container = await parseWorkflowContainer(ej.workflowPath, actualTaskName);
|
|
639
|
+
const job = buildJob(ej);
|
|
640
|
+
job.steps = steps;
|
|
641
|
+
job.services = services;
|
|
642
|
+
job.container = container ?? undefined;
|
|
643
|
+
const result = await executeLocalJob(job, { pauseOnFailure, store });
|
|
644
|
+
// result.outputs now contains raw step outputs (extracted inside executeLocalJob
|
|
645
|
+
// before workspace cleanup). Resolve them to job-level outputs using the
|
|
646
|
+
// output definitions from the workflow YAML.
|
|
647
|
+
if (result.outputs && Object.keys(result.outputs).length > 0) {
|
|
648
|
+
const outputDefs = parseJobOutputDefs(ej.workflowPath, actualTaskName);
|
|
649
|
+
if (Object.keys(outputDefs).length > 0) {
|
|
650
|
+
result.outputs = resolveJobOutputs(outputDefs, result.outputs);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return result;
|
|
654
|
+
};
|
|
655
|
+
pruneOrphanedDockerResources();
|
|
656
|
+
const limiter = createConcurrencyLimiter(maxJobs);
|
|
657
|
+
const allResults = [];
|
|
658
|
+
// Accumulate job outputs across waves for needs.*.outputs.* resolution
|
|
659
|
+
const jobOutputs = new Map();
|
|
660
|
+
// ── Dependency-aware wave scheduling ──────────────────────────────────────
|
|
661
|
+
const deps = new Map();
|
|
662
|
+
for (const entry of expandedEntries) {
|
|
663
|
+
deps.set(entry.id, entry.needs);
|
|
664
|
+
}
|
|
665
|
+
const waves = topoSort(deps);
|
|
666
|
+
const taskNamesInWf = new Set(expandedJobs.map((j) => j.taskName));
|
|
667
|
+
const filteredWaves = waves
|
|
668
|
+
.map((wave) => wave.filter((jobId) => taskNamesInWf.has(jobId)))
|
|
669
|
+
.filter((wave) => wave.length > 0);
|
|
670
|
+
if (filteredWaves.length === 0) {
|
|
671
|
+
filteredWaves.push(Array.from(taskNamesInWf));
|
|
672
|
+
}
|
|
673
|
+
/** Build a needsContext for a job from its dependencies' accumulated outputs */
|
|
674
|
+
const buildNeedsContext = (jobId) => {
|
|
675
|
+
const jobDeps = deps.get(jobId);
|
|
676
|
+
if (!jobDeps || jobDeps.length === 0) {
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
const ctx = {};
|
|
680
|
+
for (const depId of jobDeps) {
|
|
681
|
+
ctx[depId] = jobOutputs.get(depId) ?? {};
|
|
682
|
+
if (depId.includes("/")) {
|
|
683
|
+
const callerJobId = depId.split("/")[0];
|
|
684
|
+
const calledJobId = depId.split("/").slice(1).join("/");
|
|
685
|
+
// For composite IDs like "lint/setup", also add the called job ID ("setup")
|
|
686
|
+
// so intra-workflow `needs.setup.outputs.*` references resolve correctly
|
|
687
|
+
if (!ctx[calledJobId]) {
|
|
688
|
+
ctx[calledJobId] = jobOutputs.get(depId) ?? {};
|
|
689
|
+
}
|
|
690
|
+
// If workflow_call outputs were resolved for the caller (e.g. "lint"),
|
|
691
|
+
// add them so downstream `needs.lint.outputs.*` references work
|
|
692
|
+
if (jobOutputs.has(callerJobId)) {
|
|
693
|
+
ctx[callerJobId] = jobOutputs.get(callerJobId);
|
|
638
694
|
}
|
|
639
|
-
return false;
|
|
640
695
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
696
|
+
}
|
|
697
|
+
return Object.keys(ctx).length > 0 ? ctx : undefined;
|
|
698
|
+
};
|
|
699
|
+
/** Collect outputs from a completed job result */
|
|
700
|
+
const collectOutputs = (result, taskName) => {
|
|
701
|
+
if (result.outputs && Object.keys(result.outputs).length > 0) {
|
|
702
|
+
jobOutputs.set(taskName, result.outputs);
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
/**
|
|
706
|
+
* After a wave completes, resolve workflow_call outputs for any caller jobs
|
|
707
|
+
* whose sub-jobs have all finished. This allows downstream jobs to access
|
|
708
|
+
* `needs.<callerJobId>.outputs.*`.
|
|
709
|
+
*/
|
|
710
|
+
const resolveWorkflowCallOutputs = () => {
|
|
711
|
+
// Group expanded jobs by callerJobId
|
|
712
|
+
const byCallerJobId = new Map();
|
|
713
|
+
for (const ej of expandedJobs) {
|
|
714
|
+
if (ej.callerJobId) {
|
|
715
|
+
const group = byCallerJobId.get(ej.callerJobId) ?? [];
|
|
716
|
+
group.push(ej);
|
|
717
|
+
byCallerJobId.set(ej.callerJobId, group);
|
|
646
718
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
name: `agent-ci-skipped-${ej.taskName}`,
|
|
653
|
-
workflow: path.basename(workflowPath),
|
|
654
|
-
taskId: ej.taskName,
|
|
655
|
-
succeeded: true,
|
|
656
|
-
durationMs: 0,
|
|
657
|
-
debugLogPath: "",
|
|
658
|
-
steps: [],
|
|
659
|
-
});
|
|
660
|
-
/** Run a job or skip it based on if: condition */
|
|
661
|
-
const runOrSkipJob = async (ej) => {
|
|
662
|
-
if (shouldSkipJob(ej.taskName)) {
|
|
663
|
-
debugCli(`Skipping ${ej.taskName} (if: condition is false)`);
|
|
664
|
-
const result = skippedResult(ej);
|
|
665
|
-
jobResultStatus.set(ej.taskName, "skipped");
|
|
666
|
-
return result;
|
|
667
|
-
}
|
|
668
|
-
const ctx = buildNeedsContext(ej.taskName);
|
|
669
|
-
const result = await runJob(ej, ctx);
|
|
670
|
-
jobResultStatus.set(ej.taskName, result.succeeded ? "success" : "failure");
|
|
671
|
-
collectOutputs(result, ej.taskName);
|
|
672
|
-
return result;
|
|
673
|
-
};
|
|
674
|
-
for (let wi = 0; wi < filteredWaves.length; wi++) {
|
|
675
|
-
const waveJobIds = new Set(filteredWaves[wi]);
|
|
676
|
-
const waveJobs = expandedJobs.filter((j) => waveJobIds.has(j.taskName));
|
|
677
|
-
if (waveJobs.length === 0) {
|
|
719
|
+
}
|
|
720
|
+
for (const [callerJobId, subJobs] of byCallerJobId) {
|
|
721
|
+
// Check if all sub-jobs have completed (have results)
|
|
722
|
+
const allDone = subJobs.every((sj) => jobResultStatus.has(sj.taskName));
|
|
723
|
+
if (!allDone) {
|
|
678
724
|
continue;
|
|
679
725
|
}
|
|
680
|
-
//
|
|
681
|
-
if (
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
726
|
+
// Already resolved
|
|
727
|
+
if (jobOutputs.has(callerJobId)) {
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
// Find the output defs (all sub-jobs share the same defs)
|
|
731
|
+
const outputDefs = subJobs[0]?.workflowCallOutputDefs;
|
|
732
|
+
if (!outputDefs || Object.keys(outputDefs).length === 0) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
// Resolve each output value expression: ${{ jobs.<id>.outputs.<name> }}
|
|
736
|
+
const resolved = {};
|
|
737
|
+
for (const [outputName, valueExpr] of Object.entries(outputDefs)) {
|
|
738
|
+
resolved[outputName] = valueExpr.replace(/\$\{\{\s*jobs\.([^.]+)\.outputs\.([^}\s]+)\s*\}\}/g, (_match, jobId, outputKey) => {
|
|
739
|
+
const compositeId = `${callerJobId}/${jobId}`;
|
|
740
|
+
return jobOutputs.get(compositeId)?.[outputKey] ?? "";
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
jobOutputs.set(callerJobId, resolved);
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
// Track job results for if-condition evaluation (success/failure status)
|
|
747
|
+
const jobResultStatus = new Map();
|
|
748
|
+
/** Check if a job should be skipped based on its if: condition */
|
|
749
|
+
const shouldSkipJob = (jobId, ej) => {
|
|
750
|
+
const ejWorkflowPath = ej?.workflowPath ?? workflowPath;
|
|
751
|
+
const actualTaskName = ej?.sourceTaskName ?? jobId;
|
|
752
|
+
const ifExpr = parseJobIf(ejWorkflowPath, actualTaskName);
|
|
753
|
+
if (ifExpr === null) {
|
|
754
|
+
// No if: condition — default behavior is success() (skip if any upstream failed)
|
|
755
|
+
const jobDeps = deps.get(jobId);
|
|
756
|
+
if (jobDeps && jobDeps.length > 0) {
|
|
757
|
+
const anyFailed = jobDeps.some((d) => jobResultStatus.get(d) === "failure");
|
|
758
|
+
if (anyFailed) {
|
|
759
|
+
return true;
|
|
699
760
|
}
|
|
700
|
-
warm = true;
|
|
701
761
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
// Build upstream job results for the evaluator
|
|
765
|
+
const upstreamResults = {};
|
|
766
|
+
const jobDeps = deps.get(jobId) ?? [];
|
|
767
|
+
for (const depId of jobDeps) {
|
|
768
|
+
upstreamResults[depId] = jobResultStatus.get(depId) ?? "success";
|
|
769
|
+
}
|
|
770
|
+
const needsCtx = buildNeedsContext(jobId);
|
|
771
|
+
return !evaluateJobIf(ifExpr, upstreamResults, needsCtx);
|
|
772
|
+
};
|
|
773
|
+
/** Create a synthetic skipped result for a job that was skipped by if: */
|
|
774
|
+
const skippedResult = (ej) => ({
|
|
775
|
+
name: `agent-ci-skipped-${ej.taskName}`,
|
|
776
|
+
workflow: path.basename(ej.workflowPath),
|
|
777
|
+
taskId: ej.taskName,
|
|
778
|
+
succeeded: true,
|
|
779
|
+
durationMs: 0,
|
|
780
|
+
debugLogPath: "",
|
|
781
|
+
steps: [],
|
|
782
|
+
});
|
|
783
|
+
/** Run a job or skip it based on if: condition */
|
|
784
|
+
const runOrSkipJob = async (ej) => {
|
|
785
|
+
if (shouldSkipJob(ej.taskName, ej)) {
|
|
786
|
+
debugCli(`Skipping ${ej.taskName} (if: condition is false)`);
|
|
787
|
+
const result = skippedResult(ej);
|
|
788
|
+
jobResultStatus.set(ej.taskName, "skipped");
|
|
789
|
+
return result;
|
|
790
|
+
}
|
|
791
|
+
const ctx = buildNeedsContext(ej.taskName);
|
|
792
|
+
const result = await runJob(ej, ctx);
|
|
793
|
+
jobResultStatus.set(ej.taskName, result.succeeded ? "success" : "failure");
|
|
794
|
+
collectOutputs(result, ej.taskName);
|
|
795
|
+
return result;
|
|
796
|
+
};
|
|
797
|
+
for (let wi = 0; wi < filteredWaves.length; wi++) {
|
|
798
|
+
const waveJobIds = new Set(filteredWaves[wi]);
|
|
799
|
+
const waveJobs = expandedJobs.filter((j) => waveJobIds.has(j.taskName));
|
|
800
|
+
if (waveJobs.length === 0) {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
// ── Warm-cache serialization for the first wave ────────────────────────
|
|
804
|
+
if (!warm && wi === 0 && waveJobs.length > 1) {
|
|
805
|
+
debugCli("Cold cache — running first job to populate warm modules...");
|
|
806
|
+
const firstResult = await runOrSkipJob(waveJobs[0]);
|
|
807
|
+
allResults.push(firstResult);
|
|
808
|
+
const results = await Promise.allSettled(waveJobs.slice(1).map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
|
|
809
|
+
throw wrapJobError(ej.taskName, error);
|
|
810
|
+
}))));
|
|
811
|
+
for (const r of results) {
|
|
812
|
+
if (r.status === "fulfilled") {
|
|
813
|
+
allResults.push(r.value);
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
|
|
817
|
+
const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
|
|
818
|
+
console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
|
|
819
|
+
console.error(` Error: ${errorMessage}`);
|
|
820
|
+
allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
|
|
717
821
|
}
|
|
718
822
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
if (
|
|
727
|
-
|
|
728
|
-
break;
|
|
823
|
+
warm = true;
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
const results = await Promise.allSettled(waveJobs.map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
|
|
827
|
+
throw wrapJobError(ej.taskName, error);
|
|
828
|
+
}))));
|
|
829
|
+
for (const r of results) {
|
|
830
|
+
if (r.status === "fulfilled") {
|
|
831
|
+
allResults.push(r.value);
|
|
729
832
|
}
|
|
730
833
|
else {
|
|
731
|
-
|
|
834
|
+
const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
|
|
835
|
+
const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
|
|
836
|
+
console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
|
|
837
|
+
console.error(` Error: ${errorMessage}`);
|
|
838
|
+
allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
|
|
732
839
|
}
|
|
733
840
|
}
|
|
734
841
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
842
|
+
// After each wave, resolve workflow_call outputs for completed caller jobs
|
|
843
|
+
resolveWorkflowCallOutputs();
|
|
844
|
+
// Check whether to abort remaining waves on failure
|
|
845
|
+
const waveHadFailures = allResults.some((r) => !r.succeeded);
|
|
846
|
+
if (waveHadFailures && wi < filteredWaves.length - 1) {
|
|
847
|
+
// Check fail-fast setting for jobs in this wave
|
|
848
|
+
const waveFailFastSettings = waveJobs.map((ej) => parseFailFast(ej.workflowPath, ej.sourceTaskName ?? ej.taskName));
|
|
849
|
+
// Abort unless ALL jobs in the wave explicitly set fail-fast: false
|
|
850
|
+
const shouldAbort = !waveFailFastSettings.every((ff) => ff === false);
|
|
851
|
+
if (shouldAbort) {
|
|
852
|
+
debugCli(`Wave ${wi + 1} had failures — aborting remaining waves for ${path.basename(workflowPath)}`);
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
debugCli(`Wave ${wi + 1} had failures but fail-fast is disabled — continuing`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
740
859
|
}
|
|
860
|
+
return allResults;
|
|
741
861
|
}
|
|
742
862
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
743
863
|
function printUsage() {
|
|
@@ -772,20 +892,6 @@ function resolveRepoRootFromWorkflow(workflowPath) {
|
|
|
772
892
|
}
|
|
773
893
|
return repoRoot === "/" ? resolveRepoRoot() : repoRoot;
|
|
774
894
|
}
|
|
775
|
-
function resolveRepoInfo(repoRoot) {
|
|
776
|
-
let githubRepo = config.GITHUB_REPO;
|
|
777
|
-
try {
|
|
778
|
-
const remoteUrl = execSync("git remote get-url origin", { cwd: repoRoot }).toString().trim();
|
|
779
|
-
const match = remoteUrl.match(/[:/]([^/]+\/[^/]+)\.git$/);
|
|
780
|
-
if (match) {
|
|
781
|
-
githubRepo = match[1];
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
catch {
|
|
785
|
-
debugCli("Could not detect remote 'origin', using config default.");
|
|
786
|
-
}
|
|
787
|
-
return githubRepo;
|
|
788
|
-
}
|
|
789
895
|
function resolveHeadSha(repoRoot, sha) {
|
|
790
896
|
try {
|
|
791
897
|
return {
|
|
@@ -797,6 +903,16 @@ function resolveHeadSha(repoRoot, sha) {
|
|
|
797
903
|
throw new Error(`Failed to resolve ref: ${sha}`);
|
|
798
904
|
}
|
|
799
905
|
}
|
|
906
|
+
/** Resolve the parent commit SHA for push-event `before` context. */
|
|
907
|
+
function resolveBaseSha(repoRoot, headSha) {
|
|
908
|
+
try {
|
|
909
|
+
const ref = headSha && headSha !== "HEAD" ? `${headSha}~1` : "HEAD~1";
|
|
910
|
+
return execSync(`git rev-parse ${ref}`, { cwd: repoRoot, stdio: "pipe" }).toString().trim();
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
return undefined;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
800
916
|
run().catch((err) => {
|
|
801
917
|
console.error("[Agent CI] Fatal error:", err);
|
|
802
918
|
process.exit(1);
|