CI with project access tokens¶
Most teams stop using the web app for uploads after the first week. This guide wires AppGantry into a pipeline with a project access token.
Before you start: you need a project that already exists, project Maintainer on it — or organization Manager or Admin, which reaches every project without a per-project membership — because creating, rotating, and revoking project access tokens is Maintainer-gated, an interactive sign-in (a token cannot mint or manage another), and somewhere in your CI system to store a secret. What role the token gets is a separate question, answered in Choosing a role. When you're done your pipeline will upload a build to AppGantry on every run, without a person in the loop.
Why a project access token¶
A project access token is a long-lived credential bound to a single project, granted a capped project role, and revocable on its own.
- It's owned by the project, not a person. Nobody's departure, password change, or account deletion breaks your pipeline.
- It's capped. A
Read + Download + Uploadtoken can push builds and nothing else. - It's independently revocable. Leaking one never forces a password reset.
- It's greppable. The
ag_prj_prefix is easy to scan for in secret scanners and easy to spot in logs.
Don't use one for account-level work — password, MFA, session, or token management, and organization storage configuration all require an interactive sign-in. For your own scripting, use a personal access token.
1. Create the token¶
In the web app, open the project → Access tokens → Create token:
- Name it after the pipeline, not the person:
github-actions-acme-ios. The name is what you'll see in the audit feed. - Pick the role.
Read + Download + Uploadis the default and what a build pipeline wants. See Choosing a role. - Set an expiry. See Expiry below — the form defaults to 90 days.
- Create, then copy the value. It starts with
ag_prj_and is shown once.
You can't grant a role above your own effective role in the project — a Maintainer can't mint an Admin token. That ceiling is separate from the access needed to reach the screen at all: creating, rotating, revoking, and deleting a token needs project Maintainer (an organization Manager or Admin satisfies it too), and falling short of that answers 404 rather than 403, because every project-access denial collapses to the same not-found response.
Expiry¶
The create form's expiry control offers:
| Option | Effect |
|---|---|
| No expiration | The token never expires. Available, but not the default, and not what you want for CI |
| 7 days | |
| 30 days | |
| 60 days | |
| 90 days | The default. A fresh create form arrives with this selected |
| Custom date... | Reveals a date picker. The date must be in the future |
A custom date expires the token at the end of that day, in UTC, so a token set to expire on the 14th is still valid throughout the 14th and the date shown in the token list is the one you picked. Dates are handled in UTC throughout; if your team is far from UTC, pick a day either side of a deadline rather than the deadline itself.
The same control, with the same options and the same 90-day default, appears on the personal access token form.
Pick an expiry you will notice. A token that expires mid-release is annoying; a token that never expires is the one still in a repository three years after the pipeline was deleted.
2. Store it as a CI secret¶
Repo → Settings → Secrets and variables → Actions → New repository secret
Name it APPGANTRY_TOKEN.
Project → Pipelines → Library → Variable groups
Add APPGANTRY_TOKEN, click the lock to mark it secret, then link
the group to your pipeline.
Project → Settings → CI/CD → Variables → Add variable
Key APPGANTRY_TOKEN, tick Masked and Protected.
The organization and project identifiers are not secrets. Store them as ordinary variables so they're readable while debugging.
3. Understand the upload flow¶
Uploads are two-phase, and the artifact bytes never go through the API:
1. POST /api/v1/builds/initiate → pending_upload_id + upload_url
2. PUT <upload_url> → the raw bytes, straight to storage
3. POST /api/v1/builds/{id}/complete → the SHA-256 you computed
| Constraint | Value |
|---|---|
| Signed URL lifetime | 1 hour |
| Maximum artifact size | 500 MiB |
| Maximum pending uploads per project | 25 |
| Required header on step 2 | x-ms-blob-type: BlockBlob |
Step 2 goes to Azure Blob Storage, not to AppGantry. That means it must
not carry your AppGantry Authorization header — the signed URL is
the authorization — and it must carry x-ms-blob-type: BlockBlob,
without which Azure rejects the PUT with 400 MissingRequiredHeader.
You don't send a platform: the project already has one, and every build inherits it. You don't send a size either. See Uploading a build for the canonical contract.
4. Upload from a shell¶
#!/usr/bin/env bash
set -euo pipefail
API="https://api.appgantry.com/api/v1"
ARTIFACT="build.ipa"
AUTH="Authorization: Bearer ${APPGANTRY_TOKEN}"
SCOPE="organization_id=${APPGANTRY_ORG_ID}&project_id=${APPGANTRY_PROJECT_ID}"
# 1. Initiate.
initiate=$(curl -fsS -X POST "${API}/builds/initiate?${SCOPE}" \
-H "${AUTH}" -H "Content-Type: application/json" \
-d "$(jq -n \
--arg v "${BUILD_VERSION}" \
--argjson n "${BUILD_NUMBER}" \
--arg f "$(basename "${ARTIFACT}")" \
--arg notes "${BUILD_NOTES}" \
'{version_name: $v, build_number: $n, original_filename: $f, release_notes: $notes}')")
upload_url=$(jq -r '.upload_url' <<<"${initiate}")
pending_id=$(jq -r '.pending_upload_id' <<<"${initiate}")
# 2. PUT the bytes straight to storage. No AppGantry auth header here.
curl -fsS -X PUT "${upload_url}" \
-H "x-ms-blob-type: BlockBlob" \
--data-binary "@${ARTIFACT}"
# 3. Complete, with the checksum.
sha=$(shasum -a 256 "${ARTIFACT}" | cut -d' ' -f1)
build=$(curl -fsS -X POST "${API}/builds/${pending_id}/complete?${SCOPE}" \
-H "${AUTH}" -H "Content-Type: application/json" \
-d "$(jq -n --arg s "${sha}" '{sha256: $s}')")
echo "Uploaded build $(jq -r '.identifier' <<<"${build}")"
Check the exact request and response fields for each step in the interactive reference — it is generated from the service and won't drift.
End-to-end GitHub Actions example¶
name: Build and distribute
on:
push:
branches: [main]
jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
# ... your build steps, producing build.ipa ...
- name: Upload to AppGantry
env:
APPGANTRY_TOKEN: ${{ secrets.APPGANTRY_TOKEN }}
APPGANTRY_ORG_ID: ${{ vars.APPGANTRY_ORG_ID }}
APPGANTRY_PROJECT_ID: ${{ vars.APPGANTRY_PROJECT_ID }}
BUILD_VERSION: ${{ github.ref_name }}
BUILD_NUMBER: ${{ github.run_number }}
BUILD_NOTES: ${{ github.event.head_commit.message }}
run: ./scripts/appgantry-upload.sh
Keeping the upload in a script rather than inline YAML means you can run the same thing locally when it breaks.
Choosing a role¶
| Role | Lets the token |
|---|---|
| Read | List builds, read project metadata |
| Read + Download | The above, plus download artifacts |
| Read + Download + Upload | The above, plus upload builds and set release notes. The canonical CI role. |
| Maintainer | The above, plus manage channels and releases, hand out channel tester invitations, and change settings |
| Admin | Full control of the project, including project-level tester grants |
Reach past Read + Download + Upload only if the pipeline also
publishes releases or hands out channel invitations. If it does,
consider two tokens: one that uploads and one that publishes.
The list is a menu of what the token may do, not a floor on the
person creating it. Read + Download + Upload is what the form selects
by default; the creator's own project role only caps how far up this
table they may go.
Publishing from CI¶
Uploading a build doesn't distribute it. Either:
- turn on auto-distribute on a channel, so every new build in the project lands there without CI doing anything, or
- have the pipeline create a release on a channel after a successful
upload, which needs a
Maintainertoken.
See Channels & releases.
Rotation and revocation¶
Rotate from the project's Access tokens page. Rotation keeps the token's identity, name, role, and audit history and issues a new secret, shown once. Revocation retires it permanently. Both are recorded in the audit feed.
Zero-downtime rotation:
- Rotate; copy the new value.
- Update the CI secret.
- Run a job to confirm.
Revoked tokens stay listed so the audit trail makes sense, and can be permanently deleted afterwards.
Failure modes worth handling¶
| Symptom | Cause | Fix |
|---|---|---|
| 401 | Token expired, revoked, or wrong | Check the token; check you're not sending it to the signed URL |
| 403 | Role too low, or an interactive-only surface | Grant a higher role, or don't use a token there |
| 402 | Billing gate | See Errors → 402 |
| 413 | Artifact over 500 MiB | Shrink the artifact |
429 rate_limit_exceeded on initiate |
25 uploads already in flight | Your pipeline is initiating uploads it never completes |
409 already_exists on initiate |
That project + platform + version + build-number identity is already claimed, by a completed build or by an earlier pending upload that was never completed | Not a success — no build was created. Initiate with a fresh build number or version name, or wait for the pending row to be swept and initiate the same identity again |
400 MissingRequiredHeader from the signed PUT |
The x-ms-blob-type: BlockBlob header is missing |
Add it — this error comes from Azure, not AppGantry |
| 429 | Rate limited | Back off with jitter — see Rate limits |
Signed-URL PUT fails near the end |
The 1-hour window expired | Re-initiate; don't retry the dead URL. The expired pending row keeps claiming that build identity until it is swept, so re-initiating the same version and build number can answer 409 first — use a fresh build number, or wait |
Fail the job loudly on any of these. A pipeline that "succeeds" without uploading is worse than one that fails.
See also¶
- API conventions: envelopes, pagination, retry semantics.
- API surface map: which resource family owns the operation you need next.
- Authentication: every credential type and its lifetime.
- Authorization: roles and scopes.
- Rate limits: backing off from a 429 without wedging the pipeline.
- Webhooks: notify your systems when a release lands.