Skip to content

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 + Upload token 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 tokensCreate token:

  1. Name it after the pipeline, not the person: github-actions-acme-ios. The name is what you'll see in the audit feed.
  2. Pick the role. Read + Download + Upload is the default and what a build pipeline wants. See Choosing a role.
  3. Set an expiry. See Expiry below — the form defaults to 90 days.
  4. 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 Maintainer token.

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:

  1. Rotate; copy the new value.
  2. Update the CI secret.
  3. 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