processDockerJob function

Future<bool> processDockerJob(
  1. ApiClient apiClient,
  2. String workerId, {
  3. void onJobFound()?,
})

Processes a build job inside a Docker container on Linux. Mirrors the flow in processJob (job_executor.dart) but uses Docker instead of Lume VMs. No SSH, no VM boot wait, no IP lookup.

Implementation

Future<bool> processDockerJob(
  ApiClient apiClient,
  String workerId, {
  void Function()? onJobFound,
}) async {
  final buildJob = await apiClient.claimNextJob(null);
  if (buildJob == null) return false;

  onJobFound?.call();

  final buildJobId = buildJob.id;
  final runId = _uuid.v4();

  // Initialize Logger
  setLoggerApiClient(apiClient);

  await apiClient.createRun(buildJobId, runId);
  await apiClient.updateCheckRun(buildJob, 'in_progress');

  await logInfo(
    buildJobId,
    runId,
    'Processing job: $buildJobId for ${buildJob.owner}/${buildJob.repo} (Docker) [v$version]',
  );

  // Resolve Installation Token
  String token;
  try {
    final tokenResp = await apiClient.resolveInstallationToken(buildJobId);
    token = tokenResp['token'] as String;
  } catch (e) {
    await logError(
      buildJobId,
      runId,
      'Failed to resolve GitHub App Installation Token: $e',
    );
    await apiClient.updateRunStatus(
      buildJobId: buildJobId,
      runId: runId,
      status: 'completed',
      conclusion: 'failure',
    );
    await apiClient.completeJob(buildJobId, 'FAILURE');
    await apiClient.updateCheckRun(
      buildJob,
      'completed',
      conclusion: 'failure',
    );
    await apiClient.handleBuildJobStatusChange(buildJob, 'FAILURE');
    return true;
  }

  final owner = buildJob.owner;
  final repo = buildJob.repo;
  final commitSha = buildJob.commitSha ?? '';
  final name = containerName(workerId: workerId, buildJobId: buildJobId);

  Future<void> exec(String command) => execInContainer(
    name: name,
    command: command,
    buildJobId: buildJobId,
    runId: runId,
    token: token,
  );

  Future<bool> isCancelled() async {
    try {
      return await apiClient.isJobCancelled(buildJobId);
    } catch (_) {
      return false;
    }
  }

  try {
    final workflowFileName = buildJob.workflowFileName;
    if (workflowFileName == null || workflowFileName.isEmpty) {
      throw Exception('workflowFileName is missing');
    }

    await logInfo(buildJobId, runId, 'Workflow: $workflowFileName');

    await createContainer(name);
    await startContainer(name);

    // ── Clone repository ──
    await logInfo(buildJobId, runId, 'Cloning repository $owner/$repo...');
    final githubHost = buildJob.githubBaseUrl != null
        ? Uri.parse(buildJob.githubBaseUrl!).host
        : 'github.com';
    final cloneUrl =
        'https://x-access-token:$token@$githubHost/$owner/$repo.git';

    var cloneAttempt = 0;
    await retry(
      () => exec('git clone --depth 1 --no-checkout $cloneUrl'),
      delayFactor: const Duration(seconds: 5),
      randomizationFactor: 0,
      maxAttempts: 3,
      onRetry: (e) {
        cloneAttempt++;
        logInfo(
          buildJobId,
          runId,
          'git clone failed (attempt $cloneAttempt/3). Retrying...',
        );
      },
    );

    final pullRequestNumber = buildJob.pullRequestNumber;

    await logInfo(buildJobId, runId, 'Fetching commit $commitSha...');
    var fetchAttempt = 0;
    await retry(
      () async {
        try {
          await exec('git -C $repo fetch --depth 1 origin $commitSha');
        } catch (_) {
          if (pullRequestNumber != null) {
            await logInfo(
              buildJobId,
              runId,
              'Direct fetch failed, trying PR ref pull/$pullRequestNumber/head...',
            );
            await exec(
              'git -C $repo fetch --depth 1 origin pull/$pullRequestNumber/head',
            );
          } else {
            rethrow;
          }
        }
      },
      delayFactor: const Duration(seconds: 5),
      randomizationFactor: 0,
      maxAttempts: 3,
      onRetry: (e) {
        fetchAttempt++;
        logInfo(
          buildJobId,
          runId,
          'git fetch failed (attempt $fetchAttempt/3). Retrying...',
        );
      },
    );

    await logInfo(buildJobId, runId, 'Checking out commit $commitSha...');
    await exec('git -C $repo checkout $commitSha');
    await logInfo(buildJobId, runId, 'Repository cloned successfully');

    // Build Environment variables
    final envVars = await buildEnvVars(
      apiClient: apiClient,
      buildJob: buildJob,
      projectId: apiClient.projectId,
      buildJobId: buildJobId,
      runId: runId,
    );

    // Build Secrets (filtered by workflow references)
    final secretVars = await buildSecretVars(
      apiClient: apiClient,
      token: token,
      buildJobId: buildJobId,
      runId: runId,
      buildJob: buildJob,
    );

    final envFileLines = <String>[];
    final secretFileLines = <String>[];

    for (final entry in envVars.entries) {
      final escaped = entry.value.replaceAll('\n', '\\n');
      envFileLines.add('${entry.key}=$escaped');
    }
    for (final entry in secretVars.entries) {
      final escaped = entry.value.replaceAll('\n', '\\n');
      secretFileLines.add('${entry.key}=$escaped');
    }

    await writeFileToContainer(
      name,
      '/tmp/openci-env',
      envFileLines.join('\n'),
    );
    await writeFileToContainer(
      name,
      '/tmp/openci-secrets',
      secretFileLines.join('\n'),
    );
    await writeFileToContainer(
      name,
      '/tmp/openci-event.json',
      buildEventPayload(buildJob),
    );
    await logInfo(buildJobId, runId, 'Environment variables written');

    // ── Run act ──
    await logInfo(buildJobId, runId, 'Running workflow with act...');

    final eventType = pullRequestNumber != null ? 'pull_request' : 'push';
    final jobKey = buildJob.workflowJobKey ?? buildJob.jobKey;
    final jobFlag = jobKey != null ? '-j $jobKey ' : '';

    final matrixArgs = <String>[];
    final buildJobMatrix = buildJob.matrix;
    if (buildJobMatrix != null && buildJobMatrix.isNotEmpty) {
      for (final entry in buildJobMatrix.entries) {
        matrixArgs.add('--matrix "${entry.key}:${entry.value}"');
      }
    }
    final matrixFlag = matrixArgs.isNotEmpty ? '${matrixArgs.join(' ')} ' : '';

    final uniqueHome = '/tmp/openci-home-${_uuid.v4()}';

    final actScript = [
      'set -e',
      'mkdir -p $uniqueHome',
      'export HOME=$uniqueHome',
      'export PATH="/opt/dart-sdk/bin:/opt/flutter/bin:\$PATH"',
      'cd $repo',
      'act $eventType -W .openci/$workflowFileName '
          '$jobFlag'
          '$matrixFlag'
          '--pull=false '
          '-P macos-latest=-self-hosted '
          '-P macos-14=-self-hosted '
          '-P macos-15=-self-hosted '
          '-P ubuntu-latest=$dockerImage '
          '-e /tmp/openci-event.json '
          '--env-file /tmp/openci-env '
          '--secret-file /tmp/openci-secrets',
    ].join('\n');

    await writeFileToContainer(name, '/tmp/openci-act.sh', actScript);
    await exec('chmod +x /tmp/openci-act.sh');

    try {
      await execStreamingInContainer(
        name,
        ['/bin/bash', '-l', '/tmp/openci-act.sh'],
        buildJobId,
        runId,
        token,
        isCancelled: isCancelled,
      );

      await Future.delayed(const Duration(seconds: 5));

      await logInfo(buildJobId, runId, 'Build completed successfully');
      await updateJobFinalStatus(
        apiClient: apiClient,
        buildJob: buildJob,
        runId: runId,
        status: BuildJobStatus.SUCCESS,
        conclusion: 'success',
      );
    } on TimeoutException catch (timeoutError) {
      await logError(
        buildJobId,
        runId,
        'Job execution timed out: $timeoutError',
      );
      await updateJobFinalStatus(
        apiClient: apiClient,
        buildJob: buildJob,
        runId: runId,
        status: BuildJobStatus.TIMED_OUT,
        conclusion: 'timed_out',
      );
      return true;
    } catch (actError) {
      if (await isCancelled()) {
        await logInfo(buildJobId, runId, 'Build was cancelled by user');
        await updateJobFinalStatus(
          apiClient: apiClient,
          buildJob: buildJob,
          runId: runId,
          status: BuildJobStatus.CANCELLED,
          conclusion: 'cancelled',
        );
        return true;
      }
      await logWarning(buildJobId, runId, 'Act build failed: $actError');
      await updateJobFinalStatus(
        apiClient: apiClient,
        buildJob: buildJob,
        runId: runId,
        status: BuildJobStatus.FAILURE,
        conclusion: 'failure',
      );
      return true;
    }
  } on TimeoutException catch (e, s) {
    await logError(
      buildJobId,
      runId,
      'Job timed out: $e',
      stackTrace: s.toString(),
    );
    await updateJobFinalStatus(
      apiClient: apiClient,
      buildJob: buildJob,
      runId: runId,
      status: BuildJobStatus.TIMED_OUT,
      conclusion: 'timed_out',
    );
    return true;
  } catch (e, s) {
    await logError(
      buildJobId,
      runId,
      'Job failed: $e',
      stackTrace: s.toString(),
    );
    await updateJobFinalStatus(
      apiClient: apiClient,
      buildJob: buildJob,
      runId: runId,
      status: BuildJobStatus.FAILURE,
      conclusion: 'failure',
    );
    rethrow;
  } finally {
    await flushRemainingLogs(runId: runId);
    try {
      await stopAndRemoveContainer(name);
    } catch (e) {
      await logWarning(buildJobId, runId, 'Error removing container: $e');
    }
    await pruneStaleContainers(buildJobId, runId, workerId: workerId);
  }

  return true;
}