Skip to content

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.

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.

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: manual

deploy_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 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).

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.