@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 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";
@@ -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
- const repoSlug = resolveRepoInfo(firstRepoRoot).replace("/", "-");
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
- try {
441
- if (!fs.existsSync(workflowPath)) {
442
- throw new Error(`Workflow file not found: ${workflowPath}`);
443
- }
444
- const repoRoot = resolveRepoRootFromWorkflow(workflowPath);
445
- if (!process.env.AGENT_CI_WORKING_DIR) {
446
- setWorkingDirectory(DEFAULT_WORKING_DIR);
447
- }
448
- const { headSha, shaRef } = sha
449
- ? resolveHeadSha(repoRoot, sha)
450
- : { headSha: undefined, shaRef: undefined };
451
- const githubRepo = resolveRepoInfo(repoRoot);
452
- const [owner, name] = githubRepo.split("/");
453
- const template = await getWorkflowTemplate(workflowPath);
454
- const jobs = template.jobs.filter((j) => j.type === "job");
455
- if (jobs.length === 0) {
456
- debugCli(`[Agent CI] No jobs found in workflow: ${path.basename(workflowPath)}`);
457
- return [];
458
- }
459
- const expandedJobs = [];
460
- for (const job of jobs) {
461
- const id = job.id.toString();
462
- const matrixDef = await parseMatrixDef(workflowPath, id);
463
- if (matrixDef) {
464
- const combos = noMatrix
465
- ? collapseMatrixToSingle(matrixDef)
466
- : expandMatrixCombinations(matrixDef);
467
- const total = combos.length;
468
- for (let ci = 0; ci < combos.length; ci++) {
469
- expandedJobs.push({
470
- workflowPath,
471
- taskName: id,
472
- matrixContext: noMatrix
473
- ? combos[ci]
474
- : {
475
- ...combos[ci],
476
- __job_total: String(total),
477
- __job_index: String(ci),
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
- else {
483
- expandedJobs.push({ workflowPath, taskName: id });
484
- }
485
- }
486
- // For single-job workflows, run directly without extra orchestration
487
- if (expandedJobs.length === 1) {
488
- const ej = expandedJobs[0];
489
- const secrets = loadMachineSecrets(repoRoot);
490
- const secretsFilePath = path.join(repoRoot, ".env.agent-ci");
491
- validateSecrets(workflowPath, ej.taskName, secrets, secretsFilePath);
492
- const steps = await parseWorkflowSteps(workflowPath, ej.taskName, secrets, ej.matrixContext);
493
- const services = await parseWorkflowServices(workflowPath, ej.taskName);
494
- const container = await parseWorkflowContainer(workflowPath, ej.taskName);
495
- const job = {
496
- deliveryId: `run-${Date.now()}`,
497
- eventType: "workflow_job",
498
- githubJobId: `local-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
499
- githubRepo: githubRepo,
500
- githubToken: "mock_token",
501
- headSha: headSha,
502
- shaRef: shaRef,
503
- env: { AGENT_CI_LOCAL: "true" },
504
- repository: {
505
- name: name,
506
- full_name: githubRepo,
507
- owner: { login: owner },
508
- default_branch: "main",
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
- return result;
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
- 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,
618
552
  };
619
- /** Collect outputs from a completed job result */
620
- const collectOutputs = (result, taskName) => {
621
- if (result.outputs && Object.keys(result.outputs).length > 0) {
622
- jobOutputs.set(taskName, result.outputs);
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
- // Track job results for if-condition evaluation (success/failure status)
626
- const jobResultStatus = new Map();
627
- /** Check if a job should be skipped based on its if: condition */
628
- const shouldSkipJob = (jobId) => {
629
- const ifExpr = parseJobIf(workflowPath, jobId);
630
- if (ifExpr === null) {
631
- // No if: condition — default behavior is success() (skip if any upstream failed)
632
- const jobDeps = deps.get(jobId);
633
- if (jobDeps && jobDeps.length > 0) {
634
- const anyFailed = jobDeps.some((d) => jobResultStatus.get(d) === "failure");
635
- if (anyFailed) {
636
- return true;
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
- // Build upstream job results for the evaluator
642
- const upstreamResults = {};
643
- const jobDeps = deps.get(jobId) ?? [];
644
- for (const depId of jobDeps) {
645
- 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);
646
718
  }
647
- const needsCtx = buildNeedsContext(jobId);
648
- return !evaluateJobIf(ifExpr, upstreamResults, needsCtx);
649
- };
650
- /** Create a synthetic skipped result for a job that was skipped by if: */
651
- const skippedResult = (ej) => ({
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
- // ── Warm-cache serialization for the first wave ────────────────────────
681
- if (!warm && wi === 0 && waveJobs.length > 1) {
682
- debugCli("Cold cache — running first job to populate warm modules...");
683
- const firstResult = await runOrSkipJob(waveJobs[0]);
684
- allResults.push(firstResult);
685
- const results = await Promise.allSettled(waveJobs.slice(1).map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
686
- throw wrapJobError(ej.taskName, error);
687
- }))));
688
- for (const r of results) {
689
- if (r.status === "fulfilled") {
690
- allResults.push(r.value);
691
- }
692
- else {
693
- const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
694
- const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
695
- console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
696
- console.error(` Error: ${errorMessage}`);
697
- allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
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
- else {
703
- const results = await Promise.allSettled(waveJobs.map((ej) => limiter.run(() => runOrSkipJob(ej).catch((error) => {
704
- throw wrapJobError(ej.taskName, error);
705
- }))));
706
- for (const r of results) {
707
- if (r.status === "fulfilled") {
708
- allResults.push(r.value);
709
- }
710
- else {
711
- const taskName = isJobError(r.reason) ? r.reason.taskName : "unknown";
712
- const errorMessage = isJobError(r.reason) ? r.reason.message : String(r.reason);
713
- console.error(`\n[Agent CI] Job failed with error: ${taskName}`);
714
- console.error(` Error: ${errorMessage}`);
715
- allResults.push(createFailedJobResult(taskName, workflowPath, r.reason));
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
- // Check whether to abort remaining waves on failure
720
- const waveHadFailures = allResults.some((r) => !r.succeeded);
721
- if (waveHadFailures && wi < filteredWaves.length - 1) {
722
- // Check fail-fast setting for jobs in this wave
723
- const waveFailFastSettings = waveJobs.map((ej) => parseFailFast(workflowPath, ej.taskName));
724
- // Abort unless ALL jobs in the wave explicitly set fail-fast: false
725
- const shouldAbort = !waveFailFastSettings.every((ff) => ff === false);
726
- if (shouldAbort) {
727
- debugCli(`Wave ${wi + 1} had failures — aborting remaining waves for ${path.basename(workflowPath)}`);
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
- 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));
732
839
  }
733
840
  }
734
841
  }
735
- return allResults;
736
- }
737
- catch (error) {
738
- console.error(`[Agent CI] Failed to run workflow ${path.basename(workflowPath)}: ${error.message}`);
739
- throw error;
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);