Blog

Apex tests in GitHub Actions without a Salesforce org

A complete GitHub Actions workflow that runs Apex tests on the runner with renmy runtime: JUnit and Cobertura output, pull request annotations, caching, one secret, and no org until the deploy job.

Short version

This is a complete, copy-paste GitHub Actions workflow that runs Apex tests on the runner, with no Salesforce org anywhere in the job. It uses renmy runtime, a local Salesforce Apex runtime made by Renmy UG in Berlin, which executes Apex, SOQL, DML and Salesforce Flows against an embedded PostgreSQL on the machine that runs it. The job is a checkout, a one-line install, renmy test "*" with JUnit XML and Cobertura output, a test-results step for pull request annotations, and an artifact upload. The only secret is the licence key in NIMBUS_LICENSE_KEY; CI runs are part of the Team tier, free during the public beta. The org appears once, in the deploy job, where renmy deploy validates the same bytes in the target org before shipping them.

renmy runtime was called Nimbus until October 2026. Same tool, same binary; the record of the rename is at Nimbus is now renmy runtime.

Who this is for

DevOps engineers and developers who own a Salesforce repository on GitHub and the workflow files in it. You know GitHub Actions; a local Apex runtime may be new, so it is explained to the depth the YAML needs. The broader argument is in Salesforce CI without scratch orgs; this is the GitHub-specific build of it.

What does a Salesforce org do in a GitHub Actions workflow today?

In most Salesforce repositories, the Apex test workflow is mostly not about Apex tests. It authenticates to a Dev Hub with a JWT certificate kept in the secrets store, creates a scratch org from a finite pool, deploys the source into it (four to eight minutes), asks the org to run the tests (another three to five), and deletes the org, if nothing crashed before the delete step. The CI/CD page puts the total at 12 to 20 minutes per run and lists the ways it fails: the pool is exhausted, the certificate expired, the network to the org was slow. None of those failures is about the pull request.

The workflow looks like this because Apex has only ever run inside Salesforce, so “run the tests” has meant “rent an org for a few minutes and ask it”.

How can Apex tests run on a GitHub runner without an org?

A Java or JavaScript team’s test job is: check out, install a toolchain, run mvn test or npm test. The tests run on the runner, against a database that is a container or in-process, and nothing outside the runner is involved. A local runtime gives Apex the same shape.

renmy runtime is a local Salesforce Apex runtime: one binary for macOS, Linux and Windows that executes real Apex (classes, interfaces, enums, generics, triggers), SOQL and DML, and Salesforce Flows (autolaunched, record-triggered and screen) against an embedded PostgreSQL. It starts that database itself and tears it down when the job finishes. No Docker, no JVM, no service container, no org. Governor limits are enforced; @testSetup, Test.startTest/Test.stopTest, per-test transaction isolation and the Stub API behave as they do on the platform; triggers and flows fire in order of execution. It is not a mock framework and not a transpiler. How closely it matches the platform is a public record at renmy.net/parity.

Speed is what matters for CI. A typical Apex test completes in tens of milliseconds, SOQL and DML included. The published benchmark runs four open-source Apex suites, 837 tests: about 1 min 46 s for deploy and run in a Salesforce org against 2.4 s locally, roughly 45× faster, every test passing in both (renmy.net/benchmarks). On a GitHub runner, the checkout and the binary download take longer than the tests.

The complete workflow

.github/workflows/apex-tests.yml

yaml
name: Apex tests

on:
  pull_request:
  push:
    branches: [main]

# The test-results step writes a check run and a pull request comment.
permissions:
  contents: read
  checks: write
  pull-requests: write

env:
  # The only secret in the test job. CI runs are Team, free during the public beta.
  NIMBUS_LICENSE_KEY: ${{ secrets.NIMBUS_LICENSE_KEY }}
  # Activates the %ci. overrides in renmy.properties (see below).
  NIMBUS_PROFILE: ci

jobs:
  apex-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v4

      - name: Install renmy runtime
        run: curl -fsSL https://renmy.net/install.sh | sh

      - name: Run Apex tests
        run: |
          renmy test "*" \
            --coverage --coverage-report coverage.xml \
            --results-xml results.xml

      - name: Publish test results
        if: always()
        uses: EnricoMi/publish-unit-test-result-action@v2
        with:
          files: results.xml

      - name: Upload test results and coverage
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: apex-test-reports
          path: |
            results.xml
            coverage.xml

What each step does

Checkout. A standard SFDX project. renmy runtime reads the Apex classes, triggers, flows and metadata from the working tree; nothing is deployed anywhere first.

Install. One line puts the renmy binary on the runner. No JVM, no Docker daemon, no services: block. Its only network calls are the download and the licence check.

Run tests. renmy test "*" runs every test class in the project. --coverage measures line and branch coverage during the run, and --coverage-report coverage.xml writes it as Cobertura XML; the file extension chooses the format. --results-xml results.xml writes JUnit XML. The exit code is non-zero if any test fails, so the job works as a required status check as it is.

Publish test results. if: always() matters: a failed run must still be published, or the step that explains the failure is the one skipped. The action reads the JUnit XML, creates a check run with an annotation on each failing test, and comments the counts on the pull request; the permissions block grants the two scopes it writes with. Any JUnit reporter works; the how-to guide uses dorny/test-reporter@v1 with reporter: java-junit.

Upload artifacts. Both files stay attached to the run. To browse coverage instead, --coverage-report coverage.html writes a line-by-line HTML report meant to be shared as an artifact.

The one secret

NIMBUS_LICENSE_KEY is the entire secrets story for the test job. The key comes from the portal and goes into Settings → Secrets and variables → Actions. No JWT private key to decrypt, no consumer key, no Dev Hub username, nothing that expires on the Salesforce side. Running the suite on a CI runner is part of the Team plan, which includes a CI runner licence with its own machine pool; Team is free during the public beta, list price $49 per developer per month. The Free tier covers local runs on one machine and does not run tests in CI (pricing).

What the run produces, and which tools read it

renmy runtime writes the formats a Java, Python or Go pipeline produces, so the rest of your CI already understands them:

  • JUnit XML (--results-xml results.xml): pull request annotations, test trend tracking, failure history. GitHub Actions, GitLab CI, Jenkins and CircleCI read it.
  • Cobertura XML (--coverage-report coverage.xml): the standard coverage format. SonarQube, Codecov and GitHub Actions coverage gates read it directly. Coverage is line and branch; a Salesforce org reports only line coverage.
  • HTML (--coverage-report coverage.html): coverage line by line in a browser.
  • JSON (--coverage-report coverage.json): machine-readable coverage for your own tooling.

To send coverage to Codecov, add one step after the test run; its token is Codecov’s secret, as for any language:

yaml
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          files: coverage.xml
          token: ${{ secrets.CODECOV_TOKEN }}

For SonarQube or SonarCloud, point sonar.apex.coverage.reportPaths at coverage.xml and sonar.junit.reportPaths at results.xml in sonar-project.properties. On a pull request, renmy coverage diff reports the delta, line and branch, so a reviewer sees what the change did rather than one percentage; the coverage page has the formats and the diff. The XML outputs are part of the paid tiers; a CI run is on one anyway.

Caching for faster runs

renmy runtime caches parsed ASTs in .nimbus/cache/. On a project with hundreds of Apex classes, that cuts startup from several seconds to under a second on subsequent runs. Cache the directory between runs, keyed on the Apex sources; the binary and the embedded PostgreSQL runtime under ~/.nimbus can be cached too. These steps go after the checkout, in place of the plain install:

yaml
      - name: Cache renmy runtime
        uses: actions/cache@v4
        with:
          path: |
            ~/.nimbus
            .nimbus/cache
          key: renmy-${{ hashFiles('force-app/**/*.cls', 'force-app/**/*.trigger') }}
          restore-keys: renmy-

      - name: Cache binary
        id: renmy-bin
        uses: actions/cache@v4
        with:
          path: /usr/local/bin/renmy
          key: renmy-bin-${{ runner.os }}-v1

      - name: Install renmy runtime
        if: steps.renmy-bin.outputs.cache-hit != 'true'
        run: curl -fsSL https://renmy.net/install.sh | sh

The restore-keys prefix means a pull request that touches three classes still restores the previous cache and re-parses only what changed. Bump the binary key’s -v1 suffix to pick up a new release.

The CI profile in renmy.properties

renmy runtime reads a renmy.properties file from the project root and supports Quarkus-style configuration profiles: prefix a property with %ci. and it applies only when the ci profile is active. NIMBUS_PROFILE=ci in the workflow activates it; renmy test --profile ci is the equivalent flag. CI then gets strict governor limit enforcement, parallel workers and no tracing output, defined once in source control rather than spread across pipeline variables:

properties
# renmy.properties - commit to your repo

# Defaults: relaxed for local development
renmy.governor.mode=warn
renmy.trace.enabled=true

# CI overrides - activated by NIMBUS_PROFILE=ci
%ci.renmy.governor.mode=strict
%ci.renmy.test.parallel=4
%ci.renmy.trace.enabled=false

A project set up before the rename may have a nimbus.properties file with nimbus.* keys; it keeps working. On very large schemas, the same profile can point renmy runtime at an external PostgreSQL service container through DATABASE_URL; the CI/CD page has that variant and the configuration reference has every key.

Where the org still appears: the deploy job

The test job proves the code. Shipping it needs the org, and that is the one place for Salesforce credentials in the workflow. renmy deploy is a gated one-shot deploy: it snapshots the selected source into a content-addressed bundle, runs the local test and coverage gates against that bundle, validates the identical bytes in the target org with a real Salesforce check-only validation, and only then deploys. It is in the Free tier.

yaml
  deploy:
    needs: apex-tests
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install renmy runtime
        run: curl -fsSL https://renmy.net/install.sh | sh

      - name: Log in to the target org
        # The Salesforce credentials live in this job, and only in this job.
        env:
          SF_JWT_KEY: ${{ secrets.SF_JWT_KEY }}
          SF_CONSUMER_KEY: ${{ secrets.SF_CONSUMER_KEY }}
          SF_USERNAME: ${{ secrets.SF_USERNAME }}
        run: |
          echo "$SF_JWT_KEY" > server.key
          renmy sf org login jwt --client-id "$SF_CONSUMER_KEY" \
            --jwt-key-file server.key --username "$SF_USERNAME" --alias target

      - name: Gated deploy
        # Local gates, a check-only validation of the same bytes in the org, then deploy.
        run: renmy deploy --target-org target --source-dir force-app --min-coverage 75

renmy sf <anything> passes any Salesforce CLI command through unchanged, arguments, prompts and exit codes preserved, so the login is the one you run today. renmy toolchain sf pins the Salesforce CLI version so the deploy runner does not get a new CLI every time Salesforce publishes one (guide). Teams that want a person between validation and production split the deploy with renmy release validate and renmy release deploy (Pro, free during the beta), carrying an immutable receipt through a GitHub environment approval; the deploy page covers both.

“I already have a CI sandbox” is the usual objection, and the answer is that you keep it; it stops being the thing every pull request waits for. Add the job above beside the existing scratch-org workflow, watch which one finds bugs first and which one breaks for unrelated reasons, then promote renmy runtime to the required check and demote the scratch-org workflow to a nightly fidelity gate. The comparison: renmy runtime vs scratch orgs.

Try it

bash
curl -fsSL https://renmy.net/install.sh | sh          # macOS, Linux
irm https://renmy.net/install.ps1 | iex              # Windows PowerShell
cd your-sfdx-project
renmy test "*" --coverage

The quickstart takes you from install to a first green run on your own machine before you touch the workflow. Issues, docs, examples and the released binaries are at github.com/renmy-solutions/renmy; the wiring guide for GitHub and GitLab is Apex CI without a connected org; questions go to the Slack.

Frequently asked questions

Does the GitHub Actions test job need Salesforce credentials or a Dev Hub?

No. renmy runtime runs the Apex tests on the GitHub runner against an embedded PostgreSQL it starts itself, so the test job needs no JWT certificate, no connected app, no Dev Hub and no scratch org pool. Its only secret is the licence key in NIMBUS_LICENSE_KEY. Salesforce credentials appear in the deploy job, where renmy deploy validates and deploys to the target org.

How do Apex test results and coverage get onto the pull request?

renmy test writes JUnit XML with --results-xml and Cobertura XML with --coverage-report coverage.xml. A JUnit reporter action turns the first into check-run annotations and a pull request comment; Codecov, SonarQube or a GitHub coverage status check read the second. The exit code is non-zero when a test fails, so the job also works as a plain required status check.

How long does the Apex test job take without an org?

The CI/CD page gives 30 to 90 seconds for the whole job against 12 to 20 minutes for an org-based pipeline. A typical Apex test completes in tens of milliseconds locally, so most of the job is the checkout and the binary download; caching .nimbus/cache and the binary trims it further.

Is running renmy runtime in GitHub Actions free?

Running the suite on a CI runner is part of the Team tier, which includes a CI runner licence with its own machine pool. Team is free during the public beta; the list price is $49 per developer per month. The Free tier, at $0 with no card, covers unlimited local test runs, gated deploys and IDE integration on one machine, but does not run tests in CI.

Does the same workflow work on GitLab CI or Azure Pipelines?

Yes. The renmy commands are identical on every platform; only the YAML around them changes. GitLab reads the JUnit XML through artifacts:reports:junit and shows Cobertura coverage on merge request diffs; Azure Pipelines has its own publish tasks for both formats. The CI/CD page at renmy.net/ci has the GitLab pipeline.

One install step, one test command, one upload

renmy runtime runs your Apex tests on the GitHub runner against an embedded PostgreSQL, with no scratch org, no JWT certificate and no Dev Hub in the test job, and keeps the org for the deploy.