A GitLab CI job can run a Salesforce Apex test suite without a scratch org, a sandbox, a JWT certificate or a Dev Hub. renmy runtime, a local Salesforce Apex runtime made by Renmy UG in Berlin, executes Apex, SOQL, DML and Salesforce Flows on the runner itself against an embedded PostgreSQL, so the job is one install step and one renmy test command, finishing in seconds instead of the 12 to 20 minutes an org-based run takes. artifacts:reports:junit puts the results in the merge request widget and Cobertura output marks diff coverage on the changed lines. The org comes back only when you deploy: a validate job writes a receipt, a when: manual job is the approval gate, and renmy release deploy ships exactly the validated bytes. CI runs are part of the Team tier, free during the public beta.
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 release managers who run Salesforce on GitLab: the people who own .gitlab-ci.yml, the masked variables, the connected app certificate and the channel where someone asks why the pipeline is red again. No prior contact with a local Apex runtime is assumed. The GitHub Actions twin is in Apex CI without an org; the platform-neutral case is Salesforce CI without scratch orgs.
What does an Apex test job need today?
A Python team has pip install and pytest. A Java team has mvn test. A Salesforce team has had this:
# What you are replacing
apex-tests:
script:
- sf org login jwt --client-id $CLIENT_ID ... # JWT cert in a variable, connected app in the org
- sf org create scratch --definition-file ... # a slot from a finite pool
- sf project deploy start ... # 4-8 minutes, every run
- sf apex run test ... # another 3-5 minutes
- sf org delete scratch ... # skipped if anything above crashedTwelve to twenty minutes per run, and none of it is your code executing. The job depends on a certificate that expires, a Dev Hub that rations scratch orgs, a pool that empties during a release, and a network path to Salesforce. When one fails, the merge request turns red for a reason unrelated to the change. The cost is counted in The scratch org tax.
What is a local Apex runtime?
Every line of that job exists because Apex has only ever run inside a Salesforce org. The compiler, the interpreter, the database and the test runner all live in Salesforce data centres, so “run the tests” has meant “rent an org and ask it to run them”.
renmy runtime is an implementation of the Salesforce Apex runtime for an ordinary machine, and a CI runner is an ordinary machine. It executes real Apex (classes, interfaces, enums, generics, triggers), SOQL and DML against an embedded PostgreSQL that the binary starts and stops itself, and Salesforce Flows in order of execution alongside the triggers. Governor limits are enforced; Test.startTest/Test.stopTest, @testSetup, per-test transaction isolation and the Stub API behave as on the platform. One binary for macOS, Linux and Windows, no Docker, no JVM, no org; not a mock framework, not a transpiler. How closely it matches the platform is a public record at renmy.net/parity.
A typical Apex test completes in tens of milliseconds, SOQL and DML included. The published benchmark runs four open-source Apex suites, 837 tests, both ways: about 1 min 46 s in a Salesforce org, 2.4 s locally, roughly 45× faster, every test passing in both (renmy.net/benchmarks). So the Apex test job takes the shape every other language has: install, run, report.
The .gitlab-ci.yml for Apex tests without a scratch org
The job
# .gitlab-ci.yml
stages: [test]
apex-tests:
stage: test
image: ubuntu:24.04
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
NIMBUS_PROFILE: ci
# NIMBUS_LICENSE_KEY: a masked CI/CD variable in the project settings.
# GitLab puts it in the job environment; the YAML never mentions it.
before_script:
- apt-get update -qq && apt-get install -y -qq curl
- curl -fsSL https://renmy.net/install.sh | sh
script:
- renmy test "*" --coverage --coverage-report coverage.xml --results-xml results.xml
coverage: '/Lines:\s+\d+\s*\/\s*\d+\s+\(\s*(\d+\.\d+)%\)/'
artifacts:
when: always
reports:
junit: results.xml
coverage_report:
coverage_format: cobertura
path: coverage.xmlWhat each part does
image and before_script. A plain image, one download, one binary. renmy runtime starts its own embedded PostgreSQL inside the job and stops it when the job ends; the apt-get line exists only because the stock image has no curl.
script. renmy test "*" runs every test class in the project. --coverage measures line and branch coverage while the tests run, --coverage-report coverage.xml writes it as Cobertura XML, and --results-xml results.xml writes JUnit XML. The exit code is non-zero when any test fails. NIMBUS_PROFILE: ci activates the %ci. overrides in renmy.properties, covered below; --profile ci does the same.
artifacts:reports and coverage. junit: hands the results to GitLab’s test reporting and coverage_report: feeds the merge request coverage visualisation, neither with a plugin. when: always matters: without it GitLab skips the upload on exactly the run you want to read. The coverage: regular expression picks the line-coverage percentage out of the run summary for GitLab to show and track.
The only secret
NIMBUS_LICENSE_KEY is the one variable the job needs. Create it as a masked CI/CD variable in the project settings and GitLab injects it into every job; mark it protected and it exists only on protected branches. No server.key to decrypt, no consumer key, no Dev Hub username. The key comes from the portal. Running tests on a CI runner is part of the Team tier, which includes a CI runner licence; the JUnit and Cobertura XML formats are Pro. Both are free during the public beta (pricing).
What shows up on the merge request
The test summary widget reads results.xml: how many tests ran, which newly failed and which were fixed compared with the target branch.
The diff coverage comes from coverage.xml. GitLab marks each changed line in the merge request diff as covered or not, so a reviewer sees, on the line they are reading, whether any test reached it. renmy runtime also measures branch coverage, which the org never reports, and renmy coverage diff gives the delta for the whole change (renmy.net/coverage).
The pipeline status is the merge gate. A failing test fails the job, the job fails the pipeline, and “pipelines must succeed” blocks the merge button. Because the job takes seconds, running it on every push of every merge request is no longer something you ration.
Faster runs: cache, profile, shards
Cache the parsed sources
renmy runtime caches parsed Apex in .nimbus/cache/; on a project with hundreds of classes that takes startup from several seconds to under a second. GitLab’s cache: keyword restores it between jobs; it caches only paths inside the project, so the binary is downloaded each run.
apex-tests:
cache:
key: renmy-$CI_COMMIT_REF_SLUG
paths:
- .nimbus/cache/The CI profile in renmy.properties
renmy runtime reads Quarkus-style profiles from a renmy.properties file committed to the repository. Keys prefixed %ci. apply only when NIMBUS_PROFILE=ci is set, so the behaviour of the pipeline lives in source control instead of in the variables screen:
# renmy.properties, committed to the repository
# Defaults: relaxed for local development
renmy.governor.mode=warn
renmy.trace.enabled=true
# CI overrides, active under NIMBUS_PROFILE=ci or --profile ci
%ci.renmy.governor.mode=strict
%ci.renmy.test.parallel=4
%ci.renmy.trace.enabled=falseStrict limits in CI, relaxed on the laptop: the developer wants to read the warning; the pipeline wants the breach to fail. For large schemas, %ci.renmy.db.provider=external and %ci.renmy.db.url=${DATABASE_URL} point renmy runtime at a postgres:16-alpine declared under services:, removing the embedded database’s startup time; for most projects the embedded one is fine. Full key list: renmy.net/configuration.
Shard a long suite with parallel
When a suite runs for minutes even locally, split it. renmy test --shard N/M runs slice N of M, with classes balanced by recorded duration from .nimbus/test-durations.json (commit it, or it falls back to an even split by class name) and all methods of a class kept together so @testSetup behaves correctly. GitLab’s parallel: keyword supplies the numbers:
apex-tests:
parallel: 4
script:
- renmy test "*" --shard $CI_NODE_INDEX/$CI_NODE_TOTAL --results-xml results.xmlEach shard’s JUnit XML carries a shard="N/M" attribute, and GitLab merges the four reports into one test summary. In full: Shard an Apex test suite across CI jobs.
Where the org comes back: the deploy stage
Nothing above touched Salesforce. The deploy stage has to, because the org is where the code is going.
renmy deploy: the free one-job version
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 Free. As a single deploy job after apex-tests, it is the right shape as long as the person who merges is the person who ships (renmy.net/deploy).
Validate, approve manually, deploy the receipt
When a human has to sit between validation and production, split the deploy in two with renmy release (Pro, free during the public beta). renmy release validate runs the gates and the org-side check-only validation and writes an immutable receipt under .nimbus/releases/, beside the bundle it describes: bundle digest, git revision, a one-way org fingerprint, gate results, the Salesforce validation job id and the quick-deploy expiry. It carries no org alias, no credentials and no source, so passing it between jobs as an artifact leaks nothing. This is the condensed pipeline from renmy.net/release with the test stage in front:
stages: [test, validate, deploy]
apex-tests:
stage: test
# the job from above
validate:
stage: validate
image: ubuntu:24.04
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
before_script:
- apt-get update -qq && apt-get install -y -qq curl
- curl -fsSL https://renmy.net/install.sh | sh
script:
# SF_VERSION: a CI/CD variable holding the Salesforce CLI version you pinned
- renmy toolchain sf install --version "$SF_VERSION"
- renmy release validate --release-profile ci
artifacts:
paths: [".nimbus/releases/"] # receipt + bundle; no credentials, no source
deploy:
stage: deploy
image: ubuntu:24.04
needs: [validate]
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual # the human gate
environment:
name: production # a protected environment
before_script:
- apt-get update -qq && apt-get install -y -qq curl
- curl -fsSL https://renmy.net/install.sh | sh
- renmy toolchain sf install --version "$SF_VERSION"
script:
- renmy release deploy
--receipt "$(ls .nimbus/releases/rel_*.json)"
--release-profile ci
--confirm-productionThree keywords carry the weight. when: manual is the approval: the deploy job waits until someone with the right to run it clicks. environment: name: production lets you protect that environment so only a named group can be that someone. needs: [validate] downloads the validate artifact into the deploy job’s fresh runner: that is how the receipt and bundle travel.
--confirm-production is not the approval. It is the non-interactive acknowledgement that the gate was already passed, because a runner has no terminal to type a confirmation into. A production release profile can additionally require a counter-signature, so the identity that validated cannot be the identity that ships.
renmy release deploy re-checks the receipt before sending anything: org fingerprint, bundle digest, age against the 10-day quick-deploy window, toolchain version policy, and whether baseline components drifted in the org since validation. Where Salesforce supports it, the validated job is quick-deployed with no source retransmitted; otherwise the exact stored bundle is re-sent. No gates rerun, no source is restaged, and a changed, expired or wrong-org receipt is rejected with no --force. Each failure has its own exit code: 21 a local gate, 22 Salesforce rejected the payload, 23 the deploy failed, 25 integrity violation, 27 drift, 29 missing counter-signature.
Two things the deploy job needs on disk are easy to forget: the project skeleton, because the Salesforce CLI deploys in project context, so the checkout must contain sfdx-project.json and every packageDirectories path (an empty force-app/ is fine; GitLab’s default checkout provides it, GIT_STRATEGY: none would remove it), and the same pinned Salesforce CLI as the validate job. The profile keys and the Salesforce secrets the ci release profile reads are in the release-in-CI reference. Why each receipt field exists: What a release receipt should contain.
What still needs a sandbox
“I already have a CI sandbox” is the usual objection, and the answer is that you keep it for the work that is about the org: Lightning pages, permission checks, integration tests against real endpoints, and the check-only validation of the payload, which renmy deploy and renmy release validate deliberately keep there. What moves to the runner is the stage that runs on every push and waited longest: the Apex tests. Add apex-tests beside the existing scratch-org job, watch which one finds bugs and which one breaks for unrelated reasons, then make apex-tests the required check and the org run a nightly fidelity gate. In full: renmy runtime vs scratch orgs.
Try it
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 "*" --coverageThe quickstart takes you from install to a first green run before you touch the pipeline. The GitLab and GitHub jobs are on renmy.net/ci, and issues, docs, examples and released binaries live at github.com/renmy-solutions/renmy. Questions go to the Slack.
Frequently asked questions
Does an Apex test job in GitLab CI still need a Dev Hub or a JWT certificate?
No. renmy runtime runs the Apex tests on the GitLab runner against an embedded PostgreSQL, so the test job needs no Dev Hub, no connected app, no JWT certificate and no scratch org. Its only secret is the licence key in NIMBUS_LICENSE_KEY. Only the validate and deploy jobs need the target org, because that is where the code is going.
How do Apex test results appear on a GitLab merge request?
renmy test --results-xml results.xml writes JUnit XML and --coverage-report coverage.xml writes Cobertura XML. Declaring them under artifacts:reports:junit and artifacts:reports:coverage_report makes GitLab show the test summary widget and per-line diff coverage on the merge request, with no plugin. Setting artifacts:when to always keeps the upload on failed runs.
Does renmy runtime need a special GitLab runner or a service container?
No. Any runner that can execute a shell script works. The job uses the plain ubuntu:24.04 image, installs curl, downloads the single renmy runtime binary and runs it; the embedded PostgreSQL starts inside the job. No JVM and no service container are required. A postgres service can be added for large schemas.
Is --confirm-production the approval step?
No. The approval is the when: manual job on a protected production environment, which waits for a person allowed to run it. --confirm-production only acknowledges, non-interactively, that the gate was passed, since a runner has no terminal. renmy release deploy then re-checks the org fingerprint, bundle digest and age of the receipt before sending anything, and a receipt that no longer matches is rejected with no override flag.
What does running renmy runtime in GitLab CI cost?
CI runs are part of the Team tier, which includes a CI runner licence; the JUnit and Cobertura XML outputs and renmy release are Pro. All of it is free during the public beta; list prices are $19 per month for Pro and $49 per developer per month for Team. The Free tier covers unlimited local test runs and renmy deploy for an individual developer on one machine, no card.
Apex tests in your GitLab pipeline, no org
One install step, one renmy test command, JUnit and Cobertura on the merge request. The CI/CD page has the GitLab and GitHub jobs ready to copy.