Bitrise
This guide releases to Aether from Bitrise. A push to main runs the staging workflow; production is a second workflow nobody triggers automatically.
Before you start, create a deploy-scoped API key as described in Prerequisites. You also need the repository connected to Bitrise as an app; if it is not, add it in the Bitrise dashboard and let the setup wizard finish. The configuration below replaces whatever that wizard generated.
Store the API key as a Secret
Section titled “Store the API key as a Secret”Bitrise keeps Secrets separate from the configuration file. Open your app in the Workflow Editor, go to Secrets, and add AETHER_API_KEY with the value of an API key created in the dashboard under API Keys.
Secrets never appear in bitrise.yml, which is why the key goes here rather than in an Env Var. Leave Expose for Pull Requests? off, its default, so builds triggered by a pull request cannot read the value. Do not set BITRISE_SECRET_FILTERING to false; while filtering is on, the Bitrise CLI replaces secret values with [REDACTED] in build logs.
What the pull request setting does not cover
Section titled “What the pull request setting does not cover”Expose for Pull Requests? is about pull request builds. It says nothing about push builds. If a contributor can push a branch to your repository, and a trigger starts a build on that branch, that build reads your Secrets like any other push build. GitLab ties secrets to protected branches; Bitrise has no equivalent, so the trigger list is the only thing keeping push builds away from your Secrets.
The template narrows it from the other side, by triggering only on main:
triggers: push: - branch: mainKeep it that way. Widening the trigger to every branch, or adding a trigger map entry that matches more, hands the deploy key to whoever can push a branch. Treat push access to this repository as release capability: a build that starts on a contributor’s branch runs that branch’s code with your Secrets in its environment, whatever the workflow steps say. If a workflow has to run on other branches, do not reuse this Secret; a workflow that does not release needs no Aether key at all.
If a pull request build ever does reach the login step, it fails with AETHER_API_KEY is not set. That is the exposure setting doing its job. Fix the trigger, not the Secret.
The other variables
Section titled “The other variables”Everything that is not the key is ordinary configuration. AETHER_APP_NAME, AETHER_PLATFORM, and AETHER_CLI_VERSION live in the app envs of the configuration below, and AETHER_DEPLOYMENT and AETHER_ROLLOUT are set per workflow.
AETHER_API_URL is the server URL, not a secret. Leave it unset to use Aether’s production server. Set it as an Env Var only to target another server, such as Aether staging at https://api-staging.aetherpush.com. The template passes --serverUrl only when the variable has a value.
The workflows
Section titled “The workflows”Paste this into the Workflow Editor’s Configuration YAML tab, which is where Bitrise reads an app’s configuration from by default. To keep the file in the repository instead, commit it as bitrise.yml at the root and switch the app’s configuration storage from the Bitrise website to your repository; until you switch, Bitrise keeps running the configuration it already holds and your committed file does nothing.
It pins @aetherpush/cli@0.6.0 and fails the build if the installed version drifts.
format_version: '25'default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.gitproject_type: react-native
app: envs: - CI: "true" opts: is_expand: false - AETHER_CLI_VERSION: "0.6.0" opts: is_expand: false - AETHER_APP_NAME: "my-react-native-app" opts: is_expand: false - AETHER_PLATFORM: "android" opts: is_expand: false
workflows: deploy_staging: tools: nodejs: 22:installed triggers: push: - branch: main envs: - AETHER_DEPLOYMENT: "Staging" opts: is_expand: false - AETHER_ROLLOUT: "100%" opts: is_expand: false steps: - git-clone: {} - script: title: Install dependencies and Aether CLI inputs: - content: |- set -eu npm ci npm install -g "@aetherpush/cli@${AETHER_CLI_VERSION}" INSTALLED=$(aether --version | tr -d ' \r\n') if [ "$INSTALLED" != "$AETHER_CLI_VERSION" ]; then echo "Installed Aether CLI '$INSTALLED' does not match pinned '$AETHER_CLI_VERSION'." exit 1 fi - script: title: Log in to Aether inputs: - content: |- set -eu if [ -z "${AETHER_API_KEY:-}" ]; then echo "AETHER_API_KEY is not set. Add it as a Bitrise Secret." exit 1 fi if [ -n "${AETHER_API_URL:-}" ]; then aether login --accessKey "$AETHER_API_KEY" --serverUrl "$AETHER_API_URL" else aether login --accessKey "$AETHER_API_KEY" fi - script: title: Release React Native OTA update inputs: - content: |- set -eu aether release-react "$AETHER_APP_NAME" "$AETHER_PLATFORM" \ --deploymentName "$AETHER_DEPLOYMENT" \ --rollout "$AETHER_ROLLOUT" \ --json > release.json node --input-type=commonjs -e ' const fs = require("fs"); const raw = fs.readFileSync("release.json", "utf8").trim(); const lines = raw.split(String.fromCharCode(10)); const r = JSON.parse(lines[lines.length - 1]); const out = { label: r.label, packageHash: r.packageHash, size: r.size, appVersion: r.appVersion, releaseMethod: r.releaseMethod, rollout: r.rollout, isMandatory: r.isMandatory, isDisabled: r.isDisabled }; Object.keys(out).forEach(function (k) { if (out[k] === undefined || out[k] === null) delete out[k]; }); fs.writeFileSync("release.json.tmp", JSON.stringify(out)); fs.renameSync("release.json.tmp", "release.json"); ' cp release.json "$BITRISE_DEPLOY_DIR/release.json" - deploy-to-bitrise-io: inputs: - deploy_path: "$BITRISE_DEPLOY_DIR" - notify_user_groups: none
deploy_production: tools: nodejs: 22:installed envs: - AETHER_DEPLOYMENT: "Production" opts: is_expand: false - AETHER_ROLLOUT: "25%" opts: is_expand: false - AETHER_RELEASE_BRANCH: "main" opts: is_expand: false steps: - script: title: Require the release branch inputs: - content: |- set -eu if [ "${BITRISE_GIT_BRANCH:-}" != "$AETHER_RELEASE_BRANCH" ]; then echo "Production releases run from '$AETHER_RELEASE_BRANCH' only. This build is on '${BITRISE_GIT_BRANCH:-unknown}'." exit 1 fi - git-clone: {} - script: title: Install dependencies and Aether CLI inputs: - content: |- set -eu npm ci npm install -g "@aetherpush/cli@${AETHER_CLI_VERSION}" INSTALLED=$(aether --version | tr -d ' \r\n') if [ "$INSTALLED" != "$AETHER_CLI_VERSION" ]; then echo "Installed Aether CLI '$INSTALLED' does not match pinned '$AETHER_CLI_VERSION'." exit 1 fi - script: title: Log in to Aether inputs: - content: |- set -eu if [ -z "${AETHER_API_KEY:-}" ]; then echo "AETHER_API_KEY is not set. Add it as a Bitrise Secret." exit 1 fi if [ -n "${AETHER_API_URL:-}" ]; then aether login --accessKey "$AETHER_API_KEY" --serverUrl "$AETHER_API_URL" else aether login --accessKey "$AETHER_API_KEY" fi - script: title: Release React Native OTA update inputs: - content: |- set -eu aether release-react "$AETHER_APP_NAME" "$AETHER_PLATFORM" \ --deploymentName "$AETHER_DEPLOYMENT" \ --rollout "$AETHER_ROLLOUT" \ --json > release.json node --input-type=commonjs -e ' const fs = require("fs"); const raw = fs.readFileSync("release.json", "utf8").trim(); const lines = raw.split(String.fromCharCode(10)); const r = JSON.parse(lines[lines.length - 1]); const out = { label: r.label, packageHash: r.packageHash, size: r.size, appVersion: r.appVersion, releaseMethod: r.releaseMethod, rollout: r.rollout, isMandatory: r.isMandatory, isDisabled: r.isDisabled }; Object.keys(out).forEach(function (k) { if (out[k] === undefined || out[k] === null) delete out[k]; }); fs.writeFileSync("release.json.tmp", JSON.stringify(out)); fs.renameSync("release.json.tmp", "release.json"); ' cp release.json "$BITRISE_DEPLOY_DIR/release.json" - deploy-to-bitrise-io: inputs: - deploy_path: "$BITRISE_DEPLOY_DIR" - notify_user_groups: noneChange AETHER_APP_NAME and AETHER_PLATFORM to your app’s values.
How the two workflows differ
Section titled “How the two workflows differ”deploy_staging carries a triggers block, so Bitrise starts it on every push to main and releases at 100% rollout.
deploy_production carries no triggers block, so nothing starts it on its own. Start a build from the Bitrise UI, pick branch main and workflow deploy_production, and it releases at 25% rollout.
Absent triggers are not a lock, which is why the workflow also checks its own branch. A legacy trigger_map entry, a trigger added in the UI, a schedule, or an API call can all start deploy_production on a code event no matter what the workflow’s own block says, and a manual build lets you pick any branch. The first step compares $BITRISE_GIT_BRANCH against AETHER_RELEASE_BRANCH and fails the build when they differ, so a production release off a feature branch stops before it installs anything. That mirrors the branch conditions the GitLab, CircleCI, and Jenkins templates put on their production jobs. Change AETHER_RELEASE_BRANCH if your release branch is not main.
The guard checks the branch the build was started with, not the commit it checks out, so a build started on main with an explicit commit hash still ships that commit.
Node and the CI variable
Section titled “Node and the CI variable”tools: nodejs: 22:installed selects a preinstalled Node 22 for the workflow, matching the Node 22 the CLI requires. It takes whichever Node 22 the stack already has rather than one exact build; write the full version in place of 22:installed if you need to fix it precisely.
The CI: "true" app env makes the Aether CLI run non-interactively: the CLI switches to non-interactive mode when CI is exactly true, and setting it in the file means the template does not depend on what the runner exports. The Jenkins template does the same thing for the same reason.
The release artifact
Section titled “The release artifact”The release step writes the CLI --json output to release.json, keeps eight fields (label, packageHash, size, appVersion, releaseMethod, rollout, isMandatory, and isDisabled), then copies that file into $BITRISE_DEPLOY_DIR. The deploy-to-bitrise-io step uploads the directory, so the file lands under the build’s Artifacts tab.
--json still prints the full server response, including signed download URLs. Those URLs are not in the artifact. Recopying this template over an older one drops blobUrl, manifestBlobUrl, description, releasedBy, and uploadTime from the uploaded file. Pipelines that still have the older YAML keep uploading the full response until they recopy.
notify_user_groups: none keeps the upload from emailing your team on every release. Drop the input to get the step’s default, which notifies everyone.
Set it up
Section titled “Set it up”- Add
AETHER_API_KEYunder Secrets, leaving Expose for Pull Requests? off. - Add
AETHER_API_URLas an Env Var only if you target a non-production Aether server. - Paste the configuration into the Configuration YAML tab. If you commit it to the repository instead, switch the app’s configuration storage to the repository in the same sitting.
- Change
AETHER_APP_NAMEandAETHER_PLATFORMto your app’s values. - Check under Triggers that only
deploy_stagingis triggered, and only onmain. - Run
deploy_stagingagainst a disposable app or a staging deployment first. - Confirm
release.jsonappears under Artifacts. - Start
deploy_productionby hand, onmain, once staging looks right.
Keep this guide in sync
Section titled “Keep this guide in sync”This template is maintained at aether-cli/examples/ci/bitrise.yml. When it changes, update this guide to match. The template is the source of truth.