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 K1 — your 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 versionThe 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 publishfinds 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_TOKENas 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 attestYou 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)
| Name | Kind | Value |
|---|---|---|
I99DASH_PUBLISH_KEY | secret | the attest key's private half (./i99dash-ci) |
APPSTORE_PUBLISH_ENABLED | variable | true — only 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.pubThe 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.
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 publishFor 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.
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 publishAlready 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.
- 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 publishFlip 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 trueSet 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
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
| Symptom | Cause | Fix |
|---|---|---|
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 promote | Promoted before review approval. | Wait for approval (auto for allow-listed packages), then promote. |
| HTTP 401 at publish | The 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.