A standard Salesforce deploy validates whatever is in the worktree when you press enter, so “the tests passed” and “the deploy succeeded” are claims about two different payloads. renmy runtime, a local Salesforce Apex runtime made by Renmy UG in Berlin, closes that gap: renmy release validate snapshots the source into a content-addressed bundle, runs the local gates, has Salesforce check the same bytes, and writes an immutable receipt. The receipt travels through your platform’s approval as a pipeline artifact, and renmy release deploy on a fresh runner ships exactly those bytes or refuses with a distinct exit code. renmy deploy is the one-shot Free version; the split workflow is Pro, 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
Release managers and DevOps leads who own the path from a merged pull request to production: the pipeline YAML, the approval step, the on-call phone when a deploy sits in Pending. No prior contact with a local runtime is assumed. This article is about the deploy job; the test stage is covered in Salesforce CI without scratch orgs.
Why “tests passed” and “deploy succeeded” are different claims
In most ecosystems a release pipeline builds once and promotes the artifact: a Java team’s CI compiles a jar, tests that jar, and the same file reaches staging and then production. Salesforce deployments have never worked like that. sf project deploy start reads the worktree at the moment it runs, and the org compiles and tests whatever arrived. Validate on Tuesday and deploy on Thursday and you have validated one payload and deployed another. Nothing connects the evidence to the deployment.
renmy runtime’s answer is to make the payload a fixed object before anything is measured. The runtime itself is a local Salesforce Apex runtime: one binary for macOS, Linux and Windows that executes Apex classes and triggers, SOQL, DML and Salesforce Flows against an embedded PostgreSQL, with governor limits enforced and order of execution intact. No org, no scratch org, no sandbox, no Docker, no JVM. That is what lets the gates run on a CI runner with no org; the public parity record is at renmy.net/parity. The deploy workflow sits above the Salesforce CLI, not in place of it.
renmy deploy: the one-command form, and it is Free
# One command: local gates, org validation, deploy
renmy deploy --target-org staging --source-dir force-app
# Production needs explicit confirmation
renmy deploy --target-org production \
--manifest manifest/package.xml \
--confirm-production
# The intentionally ungated escape hatch: the raw Salesforce deploy,
# spelled the way Salesforce spells it. There is no shorter ungated alias.
renmy sf project deploy start --target-org dev --source-dir force-appFive stages run in order, every one reading the same staged bundle: snapshot, local validation, local gates (Apex tests, coverage, mutation and permission-seam thresholds, in seconds), a real check-only validation of the same bytes in the target org, then the deploy. A failure at any stage stops the run with its own exit code (21 local gate, 22 Salesforce validation, 23 deploy), and the result lands in a receipt under .nimbus/releases/. Every flag: renmy.net/deploy.
That is the right shape when one person decides and ships in the same minute. Regulated and multi-environment teams do not work that way: CI produces the proof, a human decides hours later, and the deployment runs somewhere else. For that gap the deploy is split in two, and what crosses the gap is a receipt.
What is a release receipt?
A receipt is a JSON file under .nimbus/releases/, beside the content-addressed bundle it describes. It records what was proven, against which bytes, in which org, at which moment. Six fields do the work.
- Bundle digest. A content address over the exact uncompressed bundle that validate staged. At deploy time the stored bundle is re-hashed against it; a byte that moved is a release integrity violation, exit code
25, not a warning. - Git revision. The commit the bundle was staged from, so the evidence points back at reviewable source. An uncommitted tree needs
--allow-dirty, and the receipt records that. - Org fingerprint. One-way, never the alias and never a credential. Deploying the receipt at a different org fails closed, which is why the deploy step still names its own
--target-orgor--release-profile. - Gate results. What the local gates measured against that bundle: tests, line coverage, mutation score where a threshold is set, Salesforce Code Analyzer violation counts, and permission-seam counts.
- Salesforce validation job ID. The check-only job that checked the same payload in the target org; on production targets a later quick deploy reuses it.
- Quick-deploy expiry. The exact moment the validated job stops being quick-deployable.
maxReceiptAgecaps a receipt’s age at deploy time and defaults to Salesforce’s 10-day window.
What it does not contain matters too: no org aliases, no credentials, no source, so uploading one between CI jobs leaks nothing. With signing set up once (renmy release keygen), each receipt carries a signed attestation of who validated it, and renmy release verify re-hashes the bundle and checks the signature offline.
# Prove a payload and write the receipt
renmy release validate --target-org staging
# Read the record back
renmy release list
renmy release get rel_20260720T141342_0397df68b0 --json
# Offline: re-hash the bundle, check the signature
renmy release verify rel_20260720T141342_0397df68b0
# Archive the whole evidence set for an auditor
renmy release export --org-kind production \
--since 2026-07-01 --output evidence-2026Q3.jsonImmutability is enforced at deploy time. A receipt whose source changed, whose validation expired, whose target org does not match, or whose toolchain is incompatible is rejected, and you revalidate. There is no --force, no --skip-verify, and no configuration key that reintroduces one; as the release page puts it, an override flag that exists gets used at 5pm on a Friday. The argument for each field is in What a release receipt should contain.
How do you split validation and deployment across CI jobs?
The pipeline shape is the workflow shape: a validate job that ends by uploading .nimbus/releases/, your platform’s environment protection in the middle, and a deploy job that downloads the same artifact into a fresh runner.
Job 1: validate, on whichever runner is free
renmy release validate snapshots the selected source, runs the local gates against the bundle, then has Salesforce check the same bytes in the target org. With no selector it defaults to the packageDirectories in sfdx-project.json, like sf project deploy start. It writes the receipt and the bundle under .nimbus/releases/ and stops; nothing is deployed. renmy toolchain sf install --version pins the Salesforce CLI so the runner reproduces the configured toolchain.
The approval is your platform’s, not a flag
The receipt and bundle move as an ordinary pipeline artifact through whatever approval your platform already enforces: GitHub required reviewers on a protected environment, a GitLab manual job, an Azure Environment check. --confirm-production is not that approval. It is the non-interactive acknowledgement that the gate was already passed, because a CI runner has no terminal to type a confirmation into. A production profile can also require a counter-signature, so the identity that validated a release cannot be the one that ships it.
Job 2: deploy the validated payload from a fresh runner
renmy release deploy re-checks the receipt before it does anything: org fingerprint, bundle digest, receipt age, toolchain version policy, and whether baseline components drifted in the org since validation. Where Salesforce supports it, the validated org-side job is quick-deployed within the 10-day window with no source retransmitted; otherwise the exact stored bundle is re-sent. It reruns no gates and restages no source, so the bytes cannot change between proof and deploy.
One detail is easy to get wrong: the Salesforce CLI deploys in project context, so the deploy job’s checkout must still contain sfdx-project.json and every path its packageDirectories declares. An empty force-app/ is fine.
GitHub Actions: two jobs and an environment
name: Release
on:
push:
branches: [main]
env:
# Running the release workflow in CI is part of the Team tier (free during the public beta).
NIMBUS_LICENSE_KEY: ${{ secrets.NIMBUS_LICENSE_KEY }}
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install renmy runtime
run: curl -fsSL https://renmy.net/install.sh | sh
- name: Pin the Salesforce CLI
run: renmy toolchain sf install --version ${{ vars.SF_VERSION }}
# Authenticate the Salesforce CLI to the target org here, as your pipeline does today.
- name: Gates, then a check-only validation of the same bytes in the org
run: renmy release validate --release-profile ci
- uses: actions/upload-artifact@v4
with: { name: release, path: .nimbus/releases/ } # receipt + bundle
deploy:
needs: validate
runs-on: ubuntu-latest
environment: production # required reviewers live here: the human gate
steps:
- uses: actions/checkout@v4 # the deploy still needs sfdx-project.json on disk
- name: Install renmy runtime
run: curl -fsSL https://renmy.net/install.sh | sh
- name: Pin the Salesforce CLI
run: renmy toolchain sf install --version ${{ vars.SF_VERSION }}
# Authenticate the Salesforce CLI to the target org here, as your pipeline does today.
- uses: actions/download-artifact@v4
with: { name: release, path: .nimbus/releases/ }
- name: Deploy exactly the validated payload
run: |
renmy release deploy \
--receipt "$(ls .nimbus/releases/rel_*.json)" \
--release-profile ci --confirm-productionGitLab CI/CD, condensed
validate:
script:
- renmy toolchain sf install --version "$SF_VERSION"
- renmy release validate --release-profile ci
artifacts:
paths: [".nimbus/releases/"] # receipt + bundle
deploy:
needs: [validate]
when: manual # the human gate
environment:
name: production # protected environment
script:
- renmy release deploy
--receipt "$(ls .nimbus/releases/rel_*.json)"
--release-profile ci
--confirm-productionOn Azure DevOps the same two commands sit in two stages with an Environment check between them; on a plain shell runner they are two scripts and a person. Every platform reads the same ci release profile, documented in the release-in-CI reference; the Salesforce side is in Quick deploy in Salesforce.
What each exit code means
Each failure mode gets its own exit code, so a pipeline can react without parsing prose.
21: a local gate failed on the runner; the org was never contacted.22: Salesforce rejected the payload in the check-only validation.23: the deploy itself failed.25: a release integrity violation. The stored bundle no longer matches the receipt’s digest; nothing was sent. Revalidate.27: baseline components drifted in the org after validation. Revalidate.29: a required counter-signature is missing.
renmy release watch adds 26, “submitted, outcome unconfirmed”: the deploy may still be running.
When the deploy sits in Pending
Salesforce runs one deployment at a time per org and does not show you the queue, so a deploy waiting its turn looks exactly like one that is hung. renmy release status reads the org’s deployment queue directly: each job’s position, live component and test counters, and recent history correlated with your receipts. A job Pending beyond renmy.release.stuckPendingAfter (three minutes by default) that never started is called out as stuck. It is read-only and deliberately not Pro-gated.
# Is it queued, running, or actually stuck?
renmy release status --target-org production
# Stable JSON for a scripted gate
renmy release status --json --limit 20
# Follow a job or a receipt to its outcome. Read-only: safe on anyone's deployment.
renmy release watch 0Af5g00000XXXXXCAA
renmy release watch rel_20260720T141342_0397df68b0
# Cancel the stuck Pending job and resubmit the byte-identical validated bundle
renmy release requeue rel_20260720T141342_0397df68b0renmy release watch follows a job ID or a receipt to its outcome and mirrors it in the exit code; it never cancels or resubmits. renmy release requeue cancels the stuck Pending job and resubmits the same validated bundle, which is safe because a job that never started has executed nothing org-side. It takes a receipt, never a bare job ID, and refuses anything that is not provably the stuck case. Background: The Salesforce deploy queue explained; runbook: Fix a stuck Salesforce deployment.
What the release workflow costs, and what it leaves alone
renmy release is the evidence layer and only that: it does not manage package versions, dependency-ordered installs, scratch-org pools, branch promotion or data seeding. Point it at the bundle your pipeline assembles and it adds proof around the deploy; the self-hosted assurance console is a read-only view over the receipt set for approvers and auditors.
renmy deploy, renmy sf pass-through and renmy release status are Free. Receipts, release profiles and the split workflow are Pro; running it in CI is Team. Both are free during the public beta, no card required; list prices are $19 per month for Pro and $49 per developer per month for Team. The runner reads its licence key from NIMBUS_LICENSE_KEY. Pricing: renmy.net/#pricing.
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
# Free: gates, a check-only validation of the same bytes in the org, then deploy
renmy deploy --target-org staging --source-dir force-app
# Pro, free during the beta: the receipt, and nothing deployed yet
renmy release validate --target-org stagingThe quickstart takes you from install to a first local test run. Issues, docs, examples and the released binaries are at github.com/renmy-solutions/renmy; the two-job templates for every platform are on renmy.net/release; questions go to the Slack.
Frequently asked questions
What does a renmy runtime release receipt contain?
An immutable JSON record written by renmy release validate under .nimbus/releases/: the bundle digest, the git revision, a one-way fingerprint of the target org, the gate results, the Salesforce check-only validation job ID and its exact quick-deploy expiry. It carries no org aliases, credentials or source, so it can move between CI jobs as an ordinary artifact, and renmy release deploy refuses to deploy it on any drift.
Is --confirm-production the approval step?
No. The approval is whatever your platform already enforces between the validate job and the deploy job: GitHub required reviewers, a GitLab manual job, an Azure Environment check. --confirm-production is the non-interactive acknowledgement that the gate was already passed, because a CI runner has no terminal to type a confirmation into.
What happens if the source changes between validate and deploy?
The deploy is rejected. renmy release deploy re-hashes the stored bundle against the receipt’s digest, and a byte that moved exits with code 25, a release integrity violation. The same applies to an expired validation, a different target org or an incompatible toolchain. There is no --force; you run renmy release validate again.
Do I need renmy release, or is renmy deploy enough?
renmy deploy is enough when one person decides and ships in the same minute: it snapshots the source, runs the local gates, validates the same bytes in the org and deploys, in one Free command. renmy release is for when a human decision or platform approval sits between validation and deployment, hours later and on another machine. It is Pro, free during the public beta; running it in CI is Team.
Deploy exactly what you validated
renmy runtime snapshots the payload, runs the gates on the runner, validates the same bytes in the org and writes a receipt that the deploy job cannot ship without. The one-shot form is Free.