GitLab CI
This guide releases to Aether from GitLab CI. The pipeline installs a pinned CLI, logs in, and releases to Staging automatically, with Production behind a manual job.
Before you start, create a deploy-scoped API key as described in Prerequisites.
Store the API key and server URL
Section titled “Store the API key and server URL”Add two CI/CD variables under Settings → CI/CD → Variables:
AETHER_API_KEY: an API key created in the dashboard. Set it Masked and Protected.AETHER_API_URL: the server URL. Leave it unset to use Aether’s production server; set it only to target another server.
Protected is the one that matters here. A protected variable is exposed only to pipelines running on protected branches and tags. Combined with the branch rules in the template, that keeps the key off pipelines for merge requests and feature branches. Masked hides the value in job logs. Set both on the key. The server URL is not a secret and needs neither.
If your default branch is not already protected, protect it under Settings → Repository → Protected branches, or the deploy jobs will not see the key.
The pipeline
Section titled “The pipeline”Save this as .gitlab-ci.yml. It pins @aetherpush/cli@0.6.0 and fails the build if the installed version drifts.
stages: - deploy
variables: AETHER_CLI_VERSION: "0.6.0" AETHER_APP_NAME: "my-react-native-app" AETHER_PLATFORM: "android"
.aether_release: stage: deploy image: node:22 before_script: - npm ci - npm install -g "@aetherpush/cli@${AETHER_CLI_VERSION}" - | set -eu 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 - | set -eu if [ -z "${AETHER_API_KEY:-}" ]; then echo "AETHER_API_KEY is not set. Add it as a masked, protected CI/CD variable." 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: - | set -eu aether release-react "$AETHER_APP_NAME" "$AETHER_PLATFORM" \ --deploymentName "$AETHER_DEPLOYMENT" \ --rollout "$AETHER_ROLLOUT" \ --json > release.json - | set -eu node --input-type=commonjs -e ' const fs = require("fs"); const raw = fs.readFileSync("release.json", "utf8").trim(); const rawLines = raw.split(String.fromCharCode(10)); const r = JSON.parse(rawLines[rawLines.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"); const fields = { RELEASE_LABEL: out.label, RELEASE_PACKAGE_HASH: out.packageHash, RELEASE_SIZE: out.size, RELEASE_APP_VERSION: out.appVersion, RELEASE_METHOD: out.releaseMethod, RELEASE_ROLLOUT: out.rollout, RELEASE_IS_MANDATORY: out.isMandatory, RELEASE_IS_DISABLED: out.isDisabled }; const lines = Object.entries(fields) .filter(function (entry) { return entry[1] !== undefined && entry[1] !== null; }) .map(function (entry) { return entry[0] + "=" + entry[1]; }); fs.writeFileSync("release.env", lines.join("\n") + "\n"); ' artifacts: paths: - release.json reports: dotenv: release.env expire_in: 1 week
deploy_staging: extends: .aether_release variables: AETHER_DEPLOYMENT: "Staging" AETHER_ROLLOUT: "100%" environment: name: staging rules: - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
deploy_production: extends: .aether_release variables: AETHER_DEPLOYMENT: "Production" AETHER_ROLLOUT: "25%" environment: name: production rules: - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' when: manualdeploy_staging runs on the default branch and releases at 100%. deploy_production reuses the same script with when: manual, so it shows up in the pipeline as a play button and runs only when someone triggers it, at 25% rollout.
AETHER_DEPLOYMENT and AETHER_ROLLOUT are set per job. AETHER_APP_NAME and AETHER_PLATFORM are at the top of the file; change them to your app’s values.
The release artifact
Section titled “The release artifact”The job writes the CLI --json output to release.json, then keeps eight fields: label, packageHash, size, appVersion, releaseMethod, rollout, isMandatory, and isDisabled. That file is uploaded as a job artifact. The same eight values go into release.env as a dotenv report, so later jobs can read RELEASE_LABEL and the rest without downloading the file.
--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.
GitLab keeps the artifact for a week (expire_in: 1 week).
Keep this guide in sync
Section titled “Keep this guide in sync”This template is maintained at aether-cli/examples/ci/gitlab-ci.yml. When it changes, update this guide to match. The template is the source of truth.