Skip to main content

Manually uploading mapping files from CI

Upload mapping files from CI

Embrace needs your R8 mapping file to turn obfuscated production stacktraces back into readable ones. By default the Embrace Gradle plugin uploads that file for you during the build. Some workflows need that upload to happen elsewhere, so you can turn the automatic upload off and run it as its own CI step instead.

Upload from CI when you want to:

  • Send a mapping file that is only produced after the Gradle build finishes — for example by a step that modifies your build artifacts, and produces a new mapping file in doing so.
  • Keep a failed upload from failing your build.
  • Build on agents that cannot reach Embrace's servers.
  • Retry an upload without rebuilding the app.
  • Upload only when you promote a build for release, rather than on every CI run.

Prerequisites

  • Embrace Gradle plugin 9.1.0 or later.
  • Your Embrace app ID and API token, both available in the Embrace dashboard.
  • A build variant with minification enabled. R8 produces no mapping file otherwise, so there is nothing to upload.

Step 1: turn off the automatic upload

Add this to the gradle.properties file of your project:

embrace.disableMappingFileUpload=true

This stops the plugin from uploading ProGuard/R8 mapping files and NDK symbol files during the build.

Step 2: export the build ID

Embrace associates each mapping file with a build ID that the Embrace Gradle plugin generates for every build. Your upload step needs that ID, so ask the plugin to write it out:

embrace.exportBuildInfo=true

For each minified variant, the plugin now writes a file alongside your other build outputs:

app/build/outputs/embrace/build-info/<variant>/embrace-build-info.json

It contains the build ID, your app ID, and the variant name:

{
"buildId": "A0FD1AFEE45F4D538BBCA56157E720B9",
"appId": "aBcDe",
"variantName": "release"
}

Only minified variants produce this file. A debug build produces none.

The export deliberately omits your API token. This file is a build output that lands in CI caches and artifact archives, which is the wrong place for a credential. Pass the token from your CI secret store instead, as shown below.

Step 3: install the CLI

Download embrace-cli on your build agent. It is a static binary with no runtime dependencies, so it runs on any CI image, including one with no JDK installed.

Builds are published for linux and darwin, on amd64 and arm64.

set -euo pipefail

case "$(uname -s)" in
Linux) os=linux ;;
Darwin) os=darwin ;;
*) echo "embrace-cli: no build for $(uname -s)" >&2; exit 1 ;;
esac
case "$(uname -m)" in
x86_64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*) echo "embrace-cli: no build for $(uname -m)" >&2; exit 1 ;;
esac

base=https://downloads.embrace.io/embrace-cli/latest
archive="embrace-cli_${os}_${arch}.tar.gz"

curl -fsSL --retry 3 -o "/tmp/${archive}" "${base}/${archive}"

# Optional but recommended: confirm the download arrived intact. Delete the two commands below to skip.
# On macOS, which has no sha256sum: shasum -a 256 -c --ignore-missing embrace-cli_checksums.txt
curl -fsSL --retry 3 -o /tmp/embrace-cli_checksums.txt "${base}/embrace-cli_checksums.txt"
( cd /tmp && sha256sum --check --ignore-missing embrace-cli_checksums.txt )

mkdir -p /tmp/embrace-cli
tar -xzf "/tmp/${archive}" -C /tmp/embrace-cli

That leaves the binary at /tmp/embrace-cli/embrace-cli, which the examples below use. The archive holds a README.md alongside the binary, so extracting into its own directory keeps both out of your project rather than leaving stray files next to your build files.

The checksum file lists every platform, so --ignore-missing narrows the check to the archive you downloaded — expect a single OK line. It confirms the download arrived intact; it is not a signature, since the checksums are published alongside the artifacts they describe.

Three other details are worth keeping if you adapt this.

set -euo pipefail is what makes a failed download stop the step. Without it the script runs on: the download fails, tar fails after it, and the step still exits 0 with nothing installed — the problem then surfaces as a confusing "no such file" when something tries to run the binary. GitHub Actions already runs run: blocks under bash -e, so there it is belt-and-braces, but most CI systems do not.

The download and the extraction are separate statements, rather than curl … | tar xz. A pipeline reports the exit status of its last command, so piping hides download failures entirely: a blocked host or a missing build shows up as gzip: stdin: unexpected end of file, or on macOS as nothing at all.

The case statements fail closed on an unrecognised platform. Without them an unknown uname builds a URL for a build that does not exist, and the download fails with HTTP 403 — which reads like a credentials problem rather than "there is no such build".

For a repeatable build, pin a version rather than tracking latest. Set base=https://downloads.embrace.io/embrace-cli/v0.1.0 and archive="embrace-cli_0.1.0_${os}_${arch}.tar.gz".

If your CI cannot reach downloads.embrace.io

Plenty of build environments have no general internet access, or allow-list the hosts they can reach. You have two options.

Allow-list downloads.embrace.io if your policy permits it. Note the upload itself still needs to reach dsym-store.emb-api.com, so allow-listing only the download host gets you a CLI that cannot upload.

Otherwise, mirror the binary. Download it once from a machine that does have access, verify the checksum there, and publish it wherever your builds already fetch things from — an internal artifact repository, an object-storage bucket, or baked into your CI container image. The binary is self-contained with no runtime dependencies, so a copied file is all you need; there is nothing to install. Pin a specific version when you mirror rather than tracking latest, so you know which build your CI is running, and re-mirror deliberately when you want to move up.

Step 4: upload the mapping file

Read the build ID out of the exported file and pass it to embrace-cli along with the mapping file:

BUILD_ID=$(jq -r .buildId app/build/outputs/embrace/build-info/release/embrace-build-info.json)

/tmp/embrace-cli/embrace-cli android upload-mapping \
--build-id "$BUILD_ID" \
--app-id "$EMBRACE_APP_ID" \
--token "$EMBRACE_API_TOKEN" \
app/build/outputs/mapping/release/mapping.txt

--app-id and --token fall back to the EMBRACE_APP_ID and EMBRACE_API_TOKEN environment variables, so you can drop both flags if you export those from your CI secret store.

Here is the whole thing as a GitHub Actions job:

- name: Build release APK
run: ./gradlew :app:assembleRelease

- name: Upload mapping file to Embrace
env:
EMBRACE_APP_ID: ${{ secrets.EMBRACE_APP_ID }}
EMBRACE_API_TOKEN: ${{ secrets.EMBRACE_API_TOKEN }}
run: |
archive=embrace-cli_0.1.0_linux_amd64.tar.gz
curl -fsSL --retry 3 -o "/tmp/$archive" "https://downloads.embrace.io/embrace-cli/v0.1.0/$archive"
mkdir -p /tmp/embrace-cli && tar -xzf "/tmp/$archive" -C /tmp/embrace-cli
BUILD_ID=$(jq -r .buildId app/build/outputs/embrace/build-info/release/embrace-build-info.json)
/tmp/embrace-cli/embrace-cli android upload-mapping \
--build-id "$BUILD_ID" \
app/build/outputs/mapping/release/mapping.txt

Store the mapping file as a build artifact as well. If an upload fails after your build agent is gone, an archived mapping file is what lets you retry without rebuilding.

Tune the upload for slow or unreliable networks

Two flags control how hard the upload tries before giving up. Both also read an environment variable, which helps when a wrapper script owns the command line:

FlagEnvironment variableDefaultDescription
--timeoutEMBRACE_UPLOAD_TIMEOUT10mMaximum time for a single upload attempt, such as 90s. The minimum is 30s.
--retriesEMBRACE_UPLOAD_RETRIES3Total attempts, including the first. The maximum is 10.

Retries apply only to transient failures such as network errors and 5xx responses. A 4xx response, such as a bad token or an unknown build ID, fails immediately, because retrying cannot fix it.

Mapping files are frequently large, and the CLI streams the file through compression rather than holding it in memory, so mapping file size should not be a problem. A slow link is, which is what --timeout is for. For the same reason --timeout will not accept less than 30s: a mapping file can run to hundreds of megabytes, and a shorter budget turns a slow link into --retries guaranteed timeouts rather than an upload.

If you use a plugin version earlier than 9.1.0

embrace.exportBuildInfo is available from 9.1.0. On an earlier version, have the CLI recover the build ID from the built app instead:

/tmp/embrace-cli/embrace-cli android upload-mapping \
--from-apk app/build/outputs/apk/release/app-release.apk \
app/build/outputs/mapping/release/mapping.txt

--from-apk reads the build ID back out of the app's DEX bytecode. Use --from-aab for an app bundle; the two are the same command and differ only in how the file reads.

Treat this as a fallback, and upgrade to 9.1.0 so you can use the exported build info instead. The exported value comes straight from the plugin that generated it, whereas --from-apk has to recover it from optimized bytecode, which depends on how R8 chose to compile your app and needs the mapping file to resolve the obfuscated code it looks at.

Confirm it worked

embrace-cli exits 0 on success and 1 on any failure, so your CI step fails on a bad upload without you having to inspect the output. There are no other exit codes: a rejected token, an unusable mapping file, and a network failure all exit 1, and errors are written to stderr.

A successful upload also prints the mapping file name and the build ID it was stored under:

uploaded mapping.txt for build id A0FD1AFEE45F4D538BBCA56157E720B9

To confirm end to end, trigger a crash in a build made from the same variant, then open the crash in the Embrace dashboard. A deobfuscated stacktrace means the mapping file reached Embrace and matched the build ID your app reports.

Troubleshoot

No embrace-build-info.json file. Two things stop the plugin from writing it. First, the variant must set isMinifyEnabled = true, because R8 has to run to produce a mapping file at all. Second, the plugin skips any variant whose build type name contains debug, matched case-insensitively. That is a check on the name, not on the isDebuggable flag, so a fully minified release variant named something like qaDebugBuild is skipped even though it produces a mapping file. Rename the build type if you hit this. The plugin logs the skip at info level, so run Gradle with --info to see it.

appId is empty in the exported file. The plugin takes it from app_id in your embrace-config.json, falling back to the EMBRACE_APP_ID environment variable, and neither was set for that build. This does not block the upload, since you pass --app-id separately, but setting one of them keeps your configuration in one place.

The upload reports an invalid mapping file. The CLI checks the file before sending it and rejects an empty file or one with no ProGuard class-mapping lines. The usual cause is passing the APK where the mapping file was expected. Without that check, both upload cleanly and report success, and the problem only surfaces later as stacktraces that are never deobfuscated.

Stacktraces are still obfuscated. Confirm the build ID you uploaded under matches the build the crash came from. Each build generates a new ID, so a mapping file from a different build does not apply, even for identical source.