How-to

Apex CI without a connected org

The Salesforce CI handshake (JWT cert, connected app, DevHub, scratch org pool) was never the part of CI that found bugs; it was the part that broke at 2am. With renmy runtime, Apex CI looks like CI for any other language: install a binary, run the test command, upload the result. Below is the working setup for GitHub Actions and GitLab.

Short version

The job is one install step, one test command, and one upload. The only secret is your licence key: no JWT, no connected app, and no DevHub minutes. JUnit XML feeds your CI test panel, and Cobertura XML feeds Codecov, SonarQube, or GitHub's coverage status check.

What you'll need

  • An SFDX project in version control.
  • A CI provider: GitHub Actions, GitLab CI, CircleCI, Azure Pipelines, Bitbucket Pipelines, Jenkins, or anything else that runs a shell script.
  • No Salesforce credentials.
  • A licence key for CI runs, from the portal, stored as a secret named NIMBUS_LICENSE_KEY. The free tier does not run tests in CI; pricing lists the plans that do.

GitHub Actions: a minimal working workflow

yaml
# .github/workflows/apex-tests.yml
name: Apex Tests
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install renmy
        run: curl -sSL https://renmy.net/install.sh | bash

      - name: Run tests
        env:
          NIMBUS_LICENSE_KEY: ${{ secrets.NIMBUS_LICENSE_KEY }}
        run: |
          renmy test "*" \
            --results-xml junit.xml \
            --coverage-report cobertura.xml \
            --profile ci

      - name: Publish test results
        if: always()
        uses: dorny/test-reporter@v1
        with:
          name: Apex tests
          path: junit.xml
          reporter: java-junit

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: cobertura.xml

The --profile ci flag activates CI-specific overrides in your nimbus.properties: typically stricter governor limit enforcement, parallel workers, and no browser auto-open. See configuration.

GitLab CI: a minimal working pipeline

yaml
# .gitlab-ci.yml
stages: [test]

apex-tests:
  stage: test
  image: ubuntu:24.04
  # NIMBUS_LICENSE_KEY: a masked CI/CD variable in the project settings
  before_script:
    - apt-get update -qq && apt-get install -y curl
    - curl -sSL https://renmy.net/install.sh | bash
  script:
    - renmy test "*" --results-xml junit.xml --coverage-report cobertura.xml --profile ci
  artifacts:
    when: always
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: cobertura.xml
  coverage: '/Lines:\s+\d+\s*\/\s*\d+\s+\(\s*(\d+\.\d+)%\)/'

What you get out

  • junit.xml: standard JUnit format. GitHub's test reporter, GitLab's test summary, Azure DevOps test results, Jenkins JUnit plugin all read it.
  • cobertura.xml: per-file, per-line coverage. Codecov, SonarQube, and GitHub's PR coverage diff all consume Cobertura natively.
  • Exit code: non-zero if any test fails, so your existing merge gate can use it as is.

Pre-commit hooks (optional, recommended)

Tests at 100 ms apiece means tests at pre-commit are realistic. Run only the tests affected by what changed since the last run's impact map (with no map yet, the whole suite runs):

bash
# .git/hooks/pre-commit
#!/usr/bin/env bash
set -e
renmy test --impacted

The JWT and scratch-org setup, for comparison

The old pattern:

yaml
# What you used to need
steps:
  - name: Install Salesforce CLI
    run: npm install --global @salesforce/cli
  - name: Decrypt server.key
    run: openssl enc -aes-256-cbc -d -in server.key.enc -out server.key -k $JWT_KEY_PASSPHRASE
  - name: Authenticate to DevHub
    run: sf org login jwt --client-id $CONSUMER_KEY --jwt-key-file server.key --username $DEVHUB_USERNAME --alias devhub --set-default-dev-hub
  - name: Create scratch org
    run: sf org create scratch -f config/project-scratch-def.json -d -y 1 -a ci-scratch
  - name: Push source
    run: sf project deploy start -o ci-scratch
  - name: Run tests
    run: sf apex run test --target-org ci-scratch --code-coverage --result-format junit --output-dir test-results --wait 20
  - name: Delete scratch org
    if: always()
    run: sf org delete scratch -o ci-scratch -p

Every line is a potential failure: cert rotation, DevHub limit, deploy timeout, scratch org pool exhaustion, expired connected app. The renmy runtime workflow is two steps and needs no Salesforce credentials at all.

Adopt incrementally

The easiest migration is to add the renmy runtime job alongside your existing CI. Watch which one finds bugs first and which one breaks for unrelated reasons. Promote renmy runtime to the required check; demote the scratch-org workflow to a nightly platform-fidelity gate.