i99dash docs
Native apps

Publish from CI

Wire a GitHub Actions (or any CI) pipeline that publishes your APK to i99dash on every release — push model, SSH-signed, no long-lived token stored.

This guide shows how to set up a pipeline so that pushing a release to your GitHub repo publishes the APK to i99dash automatically — and exactly how to configure a project to use it.

i99dash does not pull from your repo

The single most important thing to understand: i99dash never reaches into your GitHub repo. There is no webhook, no Personal Access Token, no polling. The flow is the opposite — a GitHub Action in your repo pushes the release to i99dash by running i99dash apk publish.

So pushing code (or an APK) to GitHub on its own does nothing. What publishes a new version is the workflow running apk publish. If no workflow fires (no tag, or the step is disabled), i99dash keeps showing the last version that was published.

Why push and not pull: the trust model is anchored on K1your SSH key signs the exact APK bytes, proving who produced them (see Signing and trust). That signature can only happen where your key lives: your CI runner. If i99dash pulled the APK instead, there would be no developer signature at upload time. Pushing keeps you as the signer and stores no long-lived secret on the platform.

The pipeline at a glance

 git tag vX.Y.Z  ──►  GitHub Actions (your repo)
                         1. build + sign the .apk (your Android keystore)
                         2. i99dash apk publish
                              • sha-256 the APK
                              • sign the manifest with your SSH key  (K1)
                              • auto-mint a short-lived publish token
                              • upload bytes  →  submit


                       i99dash: review  (or auto-approve, allow-listed)


                       i99dash apk promote --rollout N   ← still manual


                       cars poll OTA  →  offer the new version

The CLI does not build Android — you bring a release-signed .apk (built in CI or committed) and the CLI hashes, attests, uploads, and submits it.

Automation removes the manual apk publish step only. It is not "git tag = live on cars": a third-party submit still enters review, and apk promote (staged rollout) stays a separate, deliberate step. First-party packages can be allow-listed to auto-approve, but promote remains manual.

How CI authenticates

apk publish needs two things — an SSH key (signs the K1 attestation) and an access token (authorises the HTTP calls). In CI you supply the key; the token is handled for you:

  • Recommended — SSH key only. Put your SSH private key in a CI secret. When apk publish finds no token configured, it signs in with that key for a short-lived publish-scoped token automatically — a credential that can only hit the publish endpoints and re-mints itself if a slow upload outlives it. Nothing long-lived is stored.
  • Alternative — paste a token. Set I99DASH_TOKEN as a secret. Works on any CI, but a full-session token is broader than the key-only path. Prefer the key.

Configure your project

Three pieces, done once per repo.

1. apk.json

Every apk publish reads apk.json from the working directory. You can either commit a static one (and bump versionCode/versionName each release) or generate it in the workflow from the build's version + signer (recommended when you build in CI — one source of version truth). Both are shown below.

2. A dedicated attest-only signing key

Don't put your everyday login key in CI — it's also your interactive credential. Register a dedicated key with purpose=attest: it can sign K1 and obtain a publish-scoped token, but can never be traded for a full account session, so a leaked CI secret can't take over your account.

# Make a passphrase-less CI key and register its PUBLIC half on your account.
ssh-keygen -t ed25519 -N '' -f ./i99dash-ci -C 'ci-publish'
i99dash keys add ./i99dash-ci.pub --name github-actions --purpose attest

You can also create an attest key from the web console (Account → SSH keys → toggle CI-only publishing key). A passphrase-protected key can't be unlocked headlessly — use a passphrase-less dedicated key, or store the passphrase as a second secret and pass --passphrase.

3. Repo secret (+ optional enable variable)

NameKindValue
I99DASH_PUBLISH_KEYsecretthe attest key's private half (./i99dash-ci)
APPSTORE_PUBLISH_ENABLEDvariabletrueonly if you gate the step (see "Add to an existing workflow")
# Set the secret straight from the private key file, then delete your copy.
gh secret set I99DASH_PUBLISH_KEY --repo <owner>/<repo> < ./i99dash-ci
rm -f ./i99dash-ci ./i99dash-ci.pub

The private key now lives only in the GitHub secret (write-only). Revoke it any time with i99dash keys remove <id> — that doesn't touch your login key.

The publish workflow

Build + sign the APK in the runner, then generate apk.json from the build's version and signer and publish. This keeps a single source of version truth.

.github/workflows/publish-apk.yml
name: Publish APK
on:
  push:
    tags: ['v*']

jobs:
  publish:
    runs-on: ubuntu-latest
    # Recommended: gate behind a protected Environment with required reviewers.
    environment: i99dash-publish
    steps:
      - uses: actions/checkout@v4

      # 1. Build + sign with YOUR Android keystore (decode it from a secret).
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17' }
      - run: ./gradlew assembleRelease   # → app/build/outputs/apk/release/app-release.apk

      # 2. Install the dedicated attest signing key.
      - name: Install signing key
        run: |
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          printf '%s\n' "${{ secrets.I99DASH_PUBLISH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519

      # 3. Generate apk.json from the build + publish. versionCode is derived
      #    from SemVer (major*1e6+minor*1e3+patch) so it is monotonic.
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - name: Publish to i99dash
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          CORE="${VERSION%%-*}"; IFS='.' read -r MA MI PA <<< "$CORE"
          CODE=$(( ${MA:-0} * 1000000 + ${MI:-0} * 1000 + ${PA:-0} ))
          APK=app/build/outputs/apk/release/app-release.apk
          cp "$APK" ./release.apk
          APKSIGNER=$(ls "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1)
          SIGNER=$("$APKSIGNER" verify --print-certs ./release.apk \
                   | awk '/SHA-256 digest/{print $NF; exit}')
          cat > apk.json <<JSON
          {
            "id": "com.acme.dashcam",
            "versionName": "$VERSION",
            "versionCode": $CODE,
            "apkPath": "./release.apk",
            "signerSha256": "$SIGNER",
            "category": "utilities",
            "requires": { "minAndroidSdk": 29 }
          }
          JSON
          npx --yes i99dash@latest apk publish

For an app you don't build here (a prebuilt or third-party APK), commit the signed app.apk + a static apk.json and publish it — no build step. Bump versionCode in apk.json each time you drop in a new APK.

.github/workflows/publish-apk.yml
name: Publish APK
on:
  workflow_dispatch: {}
  push:
    tags: ['v*']

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - name: Install signing key
        run: |
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          printf '%s\n' "${{ secrets.I99DASH_PUBLISH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
      # Reads the committed apk.json (apkPath -> ./app.apk) and publishes it.
      - run: npx --yes i99dash@latest apk publish

Already have a release workflow (e.g. one that builds + registers an OTA self-update)? Add the store publish as a gated step so it stays inert until you set the secret + variable — nothing breaks before it's configured.

add to your existing build job, after the APK is built + signed
      - name: Publish to i99dash app store
        if: ${{ vars.APPSTORE_PUBLISH_ENABLED == 'true' }}
        env:
          I99DASH_SSH_KEY: ${{ secrets.I99DASH_PUBLISH_KEY }}
        run: |
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          printf '%s\n' "$I99DASH_SSH_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          cat > apk.json <<JSON
          { "id": "com.acme.dashcam", "versionName": "$VERSION",
            "versionCode": $CODE, "apkPath": "./$APK_NAME",
            "signerSha256": "$SIGNER", "category": "utilities",
            "requires": { "minAndroidSdk": 29 } }
          JSON
          npx --yes i99dash@latest apk publish

Flip it on once configured:

gh secret   set I99DASH_PUBLISH_KEY      --repo <owner>/<repo> < ./i99dash-ci
gh variable set APPSTORE_PUBLISH_ENABLED --repo <owner>/<repo> --body true

Set the secret before the variable — enabling the step with no key would run it without a credential and fail the job.

versionCode in CI

versionCode is the only ordering key and must be strictly greater than the package's current max on every publish (see the rules). Deriving it from SemVer — major*1000000 + minor*1000 + patch — keeps it monotonic without any state, and survives workflow edits (unlike github.run_number, which can reset). A single submit can't leap arbitrarily far above the current max (an anti-abuse cap), so keep the scheme consistent.

Other CI systems

The same pattern works on GitLab CI, Jenkins, Bitbucket, or a local script: provide the SSH key (default ~/.ssh/id_ed25519, or pass --key) and run apk publish. To inject a token instead of a key, set I99DASH_TOKEN in the environment.

What happens after publish

Submitted. The APK is uploaded and a release row is created in review.
Reviewed. An admin approves it — or, for an allow-listed first-party package, it auto-approves. Approval mints the platform envelope the car verifies offline.
Promoted. You run apk promote --rollout N to start the staged rollout. This stays manual.

Until you promote, the catalog shows the new version as the latest approved build, but cars keep the last promoted version.

Troubleshooting

SymptomCauseFix
NATIVE_SIGNER_MISMATCH (409)The APK was signed with a different cert than the one pinned on first publish.Sign with the original keystore — the signer SHA-256 is immutable per package.
NATIVE_VERSION_NOT_MONOTONIC (409)versionCode ≤ the package's current max.Bump it; use the SemVer-derived scheme above.
NATIVE_QUOTA_EXCEEDED (422)versionCode jumped too far, or the publish rate cap was hit.Keep one consistent versionCode scheme; space out submits.
NATIVE_NOT_APPROVED (409) on promotePromoted before review approval.Wait for approval (auto for allow-listed packages), then promote.
HTTP 401 at publishThe SSH key isn't registered, or not on the account that owns the package.Register the attest key with i99dash keys add … --purpose attest on the owning account.
SSH_KEY_SCOPE_FORBIDDEN (403)An attest key was used for a full login.Expected — attest keys publish only. apk publish already requests the right scope.

See Distribution and rollout for the full lifecycle and Signing and trust for what each key proves.

On this page