@redwoodjs/agent-ci 0.7.0 → 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 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 { getWorkflowTemplate, parseWorkflowSteps, parseWorkflowServices, parseWorkflowContainer, validateSecrets, parseMatrixDef, expandMatrixCombinations, collapseMatrixToSingle, isWorkflowRelevant, getChangedFiles, parseJobOutputDefs, parseJobIf, evaluateJobIf, parseFailFast, } from "./workflow/workflow-parser.js";
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 { parseJobDependencies, topoSort } from "./workflow/job-scheduler.js";
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";
@@ -245,6 +247,9 @@ async function run() {
245
247
  // One workflow = --all with a single entry.
246
248
  async function runWorkflows(options) {
247
249
  const { workflowPaths, sha, pauseOnFailure, noMatrix = false } = options;
250
+ // Suppress EventEmitter MaxListenersExceeded warnings when running many
251
+ // parallel jobs (each job adds SIGINT/SIGTERM listeners).
252
+ process.setMaxListeners(0);
248
253
  // Create the run state store — single source of truth for all progress
249
254
  const runId = `run-${Date.now()}`;
250
255
  const storeFilePath = path.join(getWorkingDirectory(), "runs", runId, "run-state.json");
@@ -322,6 +327,15 @@ async function runWorkflows(options) {
322
327
  }
323
328
  }, 80);
324
329
  }
330
+ // Top-level signal handler: exit the process after per-job handlers have
331
+ // cleaned up their containers. All listeners fire synchronously in
332
+ // registration order, so we defer the exit to let per-job handlers
333
+ // (registered later in local-job.ts) run first.
334
+ const exitOnSignal = () => {
335
+ setTimeout(() => process.exit(1), 0);
336
+ };
337
+ process.on("SIGINT", exitOnSignal);
338
+ process.on("SIGTERM", exitOnSignal);
325
339
  try {
326
340
  const allResults = [];
327
341
  if (workflowPaths.length === 1) {
@@ -339,7 +353,8 @@ async function runWorkflows(options) {
339
353
  // Multiple workflows (--all mode)
340
354
  // Determine warm-cache status from the first workflow's repo root
341
355
  const firstRepoRoot = resolveRepoRootFromWorkflow(workflowPaths[0]);
342
- const repoSlug = resolveRepoInfo(firstRepoRoot).replace("/", "-");
356
+ config.GITHUB_REPO ?? (config.GITHUB_REPO = resolveRepoSlug(firstRepoRoot));
357
+ const repoSlug = config.GITHUB_REPO.replace("/", "-");
343
358
  let lockfileHash = "no-lockfile";
344
359
  try {
345
360
  lockfileHash = computeLockfileHash(firstRepoRoot);
@@ -347,6 +362,11 @@ async function runWorkflows(options) {
347
362
  catch { }
348
363
  const warmModulesDir = path.resolve(getWorkingDirectory(), "cache", "warm-modules", repoSlug, lockfileHash);
349
364
  const warm = isWarmNodeModules(warmModulesDir);
365
+ // Pre-allocate unique run numbers so parallel workflows don't collide.
366
+ // Each workflow gets its own baseRunNum (e.g. 306, 307, 308) so their
367
+ // job suffixes (-j1, -j2, -j3) never produce duplicate container names.
368
+ const baseRunNum = getNextLogNum("agent-ci");
369
+ const runNums = workflowPaths.map((_, i) => baseRunNum + i);
350
370
  if (!warm && workflowPaths.length > 1) {
351
371
  // Cold cache — run first workflow serially to populate warm modules,
352
372
  // then launch the rest in parallel.
@@ -356,11 +376,17 @@ async function runWorkflows(options) {
356
376
  pauseOnFailure,
357
377
  noMatrix,
358
378
  store,
379
+ baseRunNum: runNums[0],
359
380
  });
360
381
  allResults.push(...firstResults);
361
- const settled = await Promise.allSettled(workflowPaths
362
- .slice(1)
363
- .map((wf) => handleWorkflow({ workflowPath: wf, sha, pauseOnFailure, noMatrix, store })));
382
+ const settled = await Promise.allSettled(workflowPaths.slice(1).map((wf, i) => handleWorkflow({
383
+ workflowPath: wf,
384
+ sha,
385
+ pauseOnFailure,
386
+ noMatrix,
387
+ store,
388
+ baseRunNum: runNums[i + 1],
389
+ })));
364
390
  for (const s of settled) {
365
391
  if (s.status === "fulfilled") {
366
392
  allResults.push(...s.value);
@@ -371,7 +397,14 @@ async function runWorkflows(options) {
371
397
  }
372
398
  }
373
399
  else {
374
- const settled = await Promise.allSettled(workflowPaths.map((wf) => handleWorkflow({ workflowPath: wf, sha, pauseOnFailure, noMatrix, store })));
400
+ const settled = await Promise.allSettled(workflowPaths.map((wf, i) => handleWorkflow({
401
+ workflowPath: wf,
402
+ sha,
403
+ pauseOnFailure,
404
+ noMatrix,
405
+ store,
406
+ baseRunNum: runNums[i],
407
+ })));
375
408
  for (const s of settled) {
376
409
  if (s.status === "fulfilled") {
377
410
  allResults.push(...s.value);
@@ -386,6 +419,8 @@ async function runWorkflows(options) {
386
419
  return allResults;
387
420
  }
388
421
  finally {
422
+ process.removeListener("SIGINT", exitOnSignal);
423
+ process.removeListener("SIGTERM", exitOnSignal);
389
424
  if (renderInterval) {
390
425
  clearInterval(renderInterval);
391
426
  }
@@ -405,307 +440,424 @@ async function runWorkflows(options) {
405
440
  async function handleWorkflow(options) {
406
441
  const { sha, pauseOnFailure, noMatrix = false, store } = options;
407
442
  let workflowPath = options.workflowPath;
408
- try {
409
- if (!fs.existsSync(workflowPath)) {
410
- throw new Error(`Workflow file not found: ${workflowPath}`);
411
- }
412
- const repoRoot = resolveRepoRootFromWorkflow(workflowPath);
413
- if (!process.env.AGENT_CI_WORKING_DIR) {
414
- setWorkingDirectory(DEFAULT_WORKING_DIR);
415
- }
416
- const { headSha, shaRef } = sha
417
- ? resolveHeadSha(repoRoot, sha)
418
- : { headSha: undefined, shaRef: undefined };
419
- const githubRepo = resolveRepoInfo(repoRoot);
420
- const [owner, name] = githubRepo.split("/");
421
- const template = await getWorkflowTemplate(workflowPath);
422
- const jobs = template.jobs.filter((j) => j.type === "job");
423
- if (jobs.length === 0) {
424
- debugCli(`[Agent CI] No jobs found in workflow: ${path.basename(workflowPath)}`);
425
- return [];
426
- }
427
- const expandedJobs = [];
428
- for (const job of jobs) {
429
- const id = job.id.toString();
430
- const matrixDef = await parseMatrixDef(workflowPath, id);
431
- if (matrixDef) {
432
- const combos = noMatrix
433
- ? collapseMatrixToSingle(matrixDef)
434
- : expandMatrixCombinations(matrixDef);
435
- const total = combos.length;
436
- for (let ci = 0; ci < combos.length; ci++) {
437
- expandedJobs.push({
438
- workflowPath,
439
- taskName: id,
440
- matrixContext: noMatrix
441
- ? combos[ci]
442
- : {
443
- ...combos[ci],
444
- __job_total: String(total),
445
- __job_index: String(ci),
446
- },
447
- });
448
- }
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
+ });
449
492
  }
450
- else {
451
- expandedJobs.push({ workflowPath, taskName: id });
452
- }
453
- }
454
- // For single-job workflows, run directly without extra orchestration
455
- if (expandedJobs.length === 1) {
456
- const ej = expandedJobs[0];
457
- const secrets = loadMachineSecrets(repoRoot);
458
- const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
459
- validateSecrets(workflowPath, ej.taskName, secrets, secretsFilePath);
460
- const steps = await parseWorkflowSteps(workflowPath, ej.taskName, secrets, ej.matrixContext);
461
- const services = await parseWorkflowServices(workflowPath, ej.taskName);
462
- const container = await parseWorkflowContainer(workflowPath, ej.taskName);
463
- const job = {
464
- deliveryId: `run-${Date.now()}`,
465
- eventType: "workflow_job",
466
- githubJobId: `local-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
467
- githubRepo: githubRepo,
468
- githubToken: "mock_token",
469
- headSha: headSha,
470
- shaRef: shaRef,
471
- env: { AGENT_CI_LOCAL: "true" },
472
- repository: {
473
- name: name,
474
- full_name: githubRepo,
475
- owner: { login: owner },
476
- default_branch: "main",
477
- },
478
- steps,
479
- services,
480
- container: container ?? undefined,
481
- workflowPath,
482
- taskId: ej.taskName,
483
- };
484
- const result = await executeLocalJob(job, { pauseOnFailure, store });
485
- return [result];
486
- }
487
- // ── Multi-job orchestration ────────────────────────────────────────────────
488
- const maxJobs = getDefaultMaxConcurrentJobs();
489
- // ── Warm-cache check ───────────────────────────────────────────────────────
490
- const repoSlug = githubRepo.replace("/", "-");
491
- let lockfileHash = "no-lockfile";
492
- try {
493
- lockfileHash = computeLockfileHash(repoRoot);
494
- }
495
- catch { }
496
- const warmModulesDir = path.resolve(getWorkingDirectory(), "cache", "warm-modules", repoSlug, lockfileHash);
497
- let warm = isWarmNodeModules(warmModulesDir);
498
- // Naming convention: agent-ci-<N>[-j<idx>][-m<shardIdx>]
499
- const baseRunNum = getNextLogNum("agent-ci");
500
- let globalIdx = 0;
501
- const buildJob = (ej) => {
502
- const secrets = loadMachineSecrets(repoRoot);
503
- const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
504
- validateSecrets(workflowPath, ej.taskName, secrets, secretsFilePath);
505
- const idx = globalIdx++;
506
- let suffix = `-j${idx + 1}`;
507
- if (ej.matrixContext) {
508
- const shardIdx = parseInt(ej.matrixContext.__job_index ?? "0", 10) + 1;
509
- suffix += `-m${shardIdx}`;
510
- }
511
- const derivedRunnerName = `agent-ci-${baseRunNum}${suffix}`;
512
- return {
513
- deliveryId: `run-${Date.now()}`,
514
- eventType: "workflow_job",
515
- githubJobId: Math.floor(Math.random() * 1000000).toString(),
516
- githubRepo: githubRepo,
517
- githubToken: "mock_token",
518
- headSha: headSha,
519
- shaRef: shaRef,
520
- env: { AGENT_CI_LOCAL: "true" },
521
- repository: {
522
- name: name,
523
- full_name: githubRepo,
524
- owner: { login: owner },
525
- default_branch: "main",
526
- },
527
- runnerName: derivedRunnerName,
528
- steps: undefined,
529
- services: undefined,
530
- container: undefined,
531
- workflowPath,
532
- taskId: ej.taskName,
533
- };
534
- };
535
- const runJob = async (ej, needsContext) => {
536
- const { taskName, matrixContext } = ej;
537
- debugCli(`Running: ${path.basename(workflowPath)} | Task: ${taskName}${matrixContext ? ` | Matrix: ${JSON.stringify(Object.fromEntries(Object.entries(matrixContext).filter(([k]) => !k.startsWith("__"))))}` : ""}`);
538
- const secrets = loadMachineSecrets(repoRoot);
539
- const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
540
- validateSecrets(workflowPath, taskName, secrets, secretsFilePath);
541
- const steps = await parseWorkflowSteps(workflowPath, taskName, secrets, matrixContext, needsContext);
542
- const services = await parseWorkflowServices(workflowPath, taskName);
543
- const container = await parseWorkflowContainer(workflowPath, taskName);
544
- const job = buildJob(ej);
545
- job.steps = steps;
546
- job.services = services;
547
- job.container = container ?? undefined;
548
- const result = await executeLocalJob(job, { pauseOnFailure, store });
549
- // result.outputs now contains raw step outputs (extracted inside executeLocalJob
550
- // before workspace cleanup). Resolve them to job-level outputs using the
551
- // output definitions from the workflow YAML.
552
- if (result.outputs && Object.keys(result.outputs).length > 0) {
553
- const outputDefs = parseJobOutputDefs(workflowPath, taskName);
554
- if (Object.keys(outputDefs).length > 0) {
555
- 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);
556
520
  }
557
521
  }
558
- return result;
559
- };
560
- pruneOrphanedDockerResources();
561
- const limiter = createConcurrencyLimiter(maxJobs);
562
- const allResults = [];
563
- // Accumulate job outputs across waves for needs.*.outputs.* resolution
564
- const jobOutputs = new Map();
565
- // ── Dependency-aware wave scheduling ──────────────────────────────────────
566
- const deps = parseJobDependencies(workflowPath);
567
- const waves = topoSort(deps);
568
- const taskNamesInWf = new Set(expandedJobs.map((j) => j.taskName));
569
- const filteredWaves = waves
570
- .map((wave) => wave.filter((jobId) => taskNamesInWf.has(jobId)))
571
- .filter((wave) => wave.length > 0);
572
- if (filteredWaves.length === 0) {
573
- filteredWaves.push(Array.from(taskNamesInWf));
574
- }
575
- /** Build a needsContext for a job from its dependencies' accumulated outputs */
576
- const buildNeedsContext = (jobId) => {
577
- const jobDeps = deps.get(jobId);
578
- if (!jobDeps || jobDeps.length === 0) {
579
- return undefined;
580
- }
581
- const ctx = {};
582
- for (const depId of jobDeps) {
583
- ctx[depId] = jobOutputs.get(depId) ?? {};
522
+ if (Object.keys(inputsContext).length === 0) {
523
+ inputsContext = undefined;
584
524
  }
585
- return ctx;
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,
586
552
  };
587
- /** Collect outputs from a completed job result */
588
- const collectOutputs = (result, taskName) => {
589
- if (result.outputs && Object.keys(result.outputs).length > 0) {
590
- jobOutputs.set(taskName, result.outputs);
591
- }
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,
592
606
  };
593
- // Track job results for if-condition evaluation (success/failure status)
594
- const jobResultStatus = new Map();
595
- /** Check if a job should be skipped based on its if: condition */
596
- const shouldSkipJob = (jobId) => {
597
- const ifExpr = parseJobIf(workflowPath, jobId);
598
- if (ifExpr === null) {
599
- // No if: condition — default behavior is success() (skip if any upstream failed)
600
- const jobDeps = deps.get(jobId);
601
- if (jobDeps && jobDeps.length > 0) {
602
- const anyFailed = jobDeps.some((d) => jobResultStatus.get(d) === "failure");
603
- if (anyFailed) {
604
- return true;
605
- }
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);
606
694
  }
607
- return false;
608
695
  }
609
- // Build upstream job results for the evaluator
610
- const upstreamResults = {};
611
- const jobDeps = deps.get(jobId) ?? [];
612
- for (const depId of jobDeps) {
613
- upstreamResults[depId] = jobResultStatus.get(depId) ?? "success";
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);
718
+ }
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) {
724
+ continue;
614
725
  }
615
- const needsCtx = buildNeedsContext(jobId);
616
- return !evaluateJobIf(ifExpr, upstreamResults, needsCtx);
617
- };
618
- /** Create a synthetic skipped result for a job that was skipped by if: */
619
- const skippedResult = (ej) => ({
620
- name: `agent-ci-skipped-${ej.taskName}`,
621
- workflow: path.basename(workflowPath),
622
- taskId: ej.taskName,
623
- succeeded: true,
624
- durationMs: 0,
625
- debugLogPath: "",
626
- steps: [],
627
- });
628
- /** Run a job or skip it based on if: condition */
629
- const runOrSkipJob = async (ej) => {
630
- if (shouldSkipJob(ej.taskName)) {
631
- debugCli(`Skipping ${ej.taskName} (if: condition is false)`);
632
- const result = skippedResult(ej);
633
- jobResultStatus.set(ej.taskName, "skipped");
634
- return result;
635
- }
636
- const ctx = buildNeedsContext(ej.taskName);
637
- const result = await runJob(ej, ctx);
638
- jobResultStatus.set(ej.taskName, result.succeeded ? "success" : "failure");
639
- collectOutputs(result, ej.taskName);
640
- return result;
641
- };
642
- for (let wi = 0; wi < filteredWaves.length; wi++) {
643
- const waveJobIds = new Set(filteredWaves[wi]);
644
- const waveJobs = expandedJobs.filter((j) => waveJobIds.has(j.taskName));
645
- if (waveJobs.length === 0) {
726
+ // Already resolved
727
+ if (jobOutputs.has(callerJobId)) {
646
728
  continue;
647
729
  }
648
- // ── Warm-cache serialization for the first wave ────────────────────────
649
- if (!warm && wi === 0 && waveJobs.length > 1) {
650
- debugCli("Cold cache running first job to populate warm modules...");
651
- const firstResult = await runOrSkipJob(waveJobs[0]);
652
- allResults.push(firstResult);
653
- const results = await Promise.allSettled(waveJobs.slice(1).map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
654
- throw wrapJobError(ej.taskName, error);
655
- }))));
656
- for (const r of results) {
657
- if (r.status === "fulfilled") {
658
- allResults.push(r.value);
659
- }
660
- else {
661
- const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
662
- const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
663
- console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
664
- console.error(` Error: ${errorMessage}`);
665
- allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
666
- }
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;
667
760
  }
668
- warm = true;
669
761
  }
670
- else {
671
- const results = await Promise.allSettled(waveJobs.map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
672
- throw wrapJobError(ej.taskName, error);
673
- }))));
674
- for (const r of results) {
675
- if (r.status === "fulfilled") {
676
- allResults.push(r.value);
677
- }
678
- else {
679
- const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
680
- const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
681
- console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
682
- console.error(` Error: ${errorMessage}`);
683
- allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
684
- }
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));
685
821
  }
686
822
  }
687
- // Check whether to abort remaining waves on failure
688
- const waveHadFailures = allResults.some((r) => !r.succeeded);
689
- if (waveHadFailures && wi < filteredWaves.length - 1) {
690
- // Check fail-fast setting for jobs in this wave
691
- const waveFailFastSettings = waveJobs.map((ej) => parseFailFast(workflowPath, ej.taskName));
692
- // Abort unless ALL jobs in the wave explicitly set fail-fast: false
693
- const shouldAbort = !waveFailFastSettings.every((ff) => ff === false);
694
- if (shouldAbort) {
695
- debugCli(`Wave ${wi + 1} had failures — aborting remaining waves for ${path.basename(workflowPath)}`);
696
- 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);
697
832
  }
698
833
  else {
699
- debugCli(`Wave ${wi + 1} had failures but fail-fast is disabled — continuing`);
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));
700
839
  }
701
840
  }
702
841
  }
703
- return allResults;
704
- }
705
- catch (error) {
706
- console.error(`[Agent CI] Failed to trigger run: ${error.message}`);
707
- return [];
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
+ }
708
859
  }
860
+ return allResults;
709
861
  }
710
862
  // ─── Utilities ────────────────────────────────────────────────────────────────
711
863
  function printUsage() {
@@ -740,20 +892,6 @@ function resolveRepoRootFromWorkflow(workflowPath) {
740
892
  }
741
893
  return repoRoot === "/" ? resolveRepoRoot() : repoRoot;
742
894
  }
743
- function resolveRepoInfo(repoRoot) {
744
- let githubRepo = config.GITHUB_REPO;
745
- try {
746
- const remoteUrl = execSync("git remote get-url origin", { cwd: repoRoot }).toString().trim();
747
- const match = remoteUrl.match(/[:/]([^/]+\/[^/]+)\.git$/);
748
- if (match) {
749
- githubRepo = match[1];
750
- }
751
- }
752
- catch {
753
- debugCli("Could not detect remote 'origin', using config default.");
754
- }
755
- return githubRepo;
756
- }
757
895
  function resolveHeadSha(repoRoot, sha) {
758
896
  try {
759
897
  return {
@@ -765,6 +903,16 @@ function resolveHeadSha(repoRoot, sha) {
765
903
  throw new Error(`Failed to resolve ref: ${sha}`);
766
904
  }
767
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
+ }
768
916
  run().catch((err) => {
769
917
  console.error("[Agent CI] Fatal error:", err);
770
918
  process.exit(1);