Releasing
Releases are cut by pushing a v* tag. .github/workflows/release.yml is the only thing that reacts to that tag, and it produces every published artifact. No version number is stored anywhere in the repository; the tag is the single source of truth.
INFO
The release workflow has no trigger other than the tag push, so it cannot be dry-run. Before tagging, exercise the build with the pre-tag check described in Checking a release build before tagging.
Cutting a release
Tag the commit and push the tag:
git tag -a v0.4.0 -m "v0.4.0"
git push origin v0.4.0The workflow creates the GitHub release immediately, then each platform job uploads its artifact as it finishes. The release is published rather than drafted, because the macOS bottle build needs the source tarball URL to be publicly fetchable.
Release artifacts
A tag produces six assets:
| Artifact | Platform | Job |
|---|---|---|
hawkeye-<version>.tar.gz | Source | source-tarball |
hawkeye-<version>.arm64_sonoma.bottle.tar.gz | macOS arm64 | bottle-arm64 |
hawkeye_<version>_amd64.deb | Linux amd64 | deb-amd64 |
hawkeye_<version>_arm64.deb | Linux arm64 | deb-arm64 |
hawkeye-<version>-windows-x64.zip | Windows x64 | windows-x64 |
hawkeye-<version>-android.apk | Android | android |
The .deb files use Debian policy naming with underscores, which is why the install command in Installation globs hawkeye_*.deb and not hawkeye-*.deb.
Every platform job depends only on source-tarball, so one platform failing does not block the release or the other artifacts. A failed job leaves the release published with that asset missing; re-upload it by hand with gh release upload <tag> <file> once the cause is fixed.
A seventh job, update-tap, is the exception. It depends on bottle-arm64 as well as source-tarball, and it pushes the updated formula to the PX4/homebrew-px4 tap. If the bottle build fails, the tap keeps pointing at the previous version while the GitHub release advertises the new one, so brew install hawkeye silently serves the old build until the job is re-run. That is the only failure in this workflow with a user-visible consequence beyond a missing asset.
How the version is derived
The source-tarball job strips the leading v from the tag and exports the result, and every other job reads it from there. The two build systems receive it differently:
| Build system | How it receives the version |
|---|---|
| CMake | -DHAWKEYE_VERSION=<version> |
| Gradle | -PhawkeyeVersionName=<version> |
android/app/build.gradle.kts computes the Android versionCode from that string as major * 100000000 + minor * 100000 + patch * 100 + rc, so CI passes one value and Gradle derives the other:
| Tag | versionName | versionCode |
|---|---|---|
v0.4.0-rc1 | 0.4.0-rc1 | 400001 |
v0.4.0 | 0.4.0 | 400099 |
v1.2.3 | 1.2.3 | 100200399 |
| no tag | 0.0.0-dev | 1 |
The rc component is what makes prerelease tags safe: a final release takes 99, an rcN suffix takes N (1 through 98), and the dev and ci fallbacks take 0, so every rc sorts below its final release, above the previous release, and each code can be uploaded to Google Play exactly once. A local build with no -PhawkeyeVersionName falls back to 0.0.0-dev, so debug builds need no extra flags. The version is parsed strictly: a tag that is not MAJOR.MINOR.PATCH with an optional rcN suffix, that has a component outside 0..999, or whose major exceeds 20 (past which the derivation overflows Google Play's version code cap) fails the Android build rather than producing a misleading version code.
The Android APK
The android job builds a single universal APK containing arm64-v8a and x86_64. There is no armeabi-v7a build, so 32-bit ARM devices are not supported. The minimum supported platform is Android 10 (API 29).
The APK is signed with the project's upload key, which the job decodes from the ANDROID_UPLOAD_KEYSTORE_BASE64, ANDROID_UPLOAD_KEYSTORE_PASSWORD, and ANDROID_UPLOAD_KEY_ALIAS repository secrets, and the asset installs as downloaded. A fork without those secrets falls back to the pre-signing behavior: the artifact is built unsigned, named hawkeye-<version>-android-unsigned.apk to make that obvious, and has to be signed before it will install; see Signing an APK yourself in the Android README.
Because the APK cannot be launched on a CI runner without an emulator, android/scripts/verify-release-apk.sh asserts on its contents instead:
- Both
lib/arm64-v8a/libhawkeye.soandlib/x86_64/libhawkeye.soare present. - All four asset trees are packaged:
assets/models/,assets/shaders/,assets/fonts/, andassets/themes/. These are symlinks into the repository root, so the check catches a runner that failed to materialize them. - The
versionNameAGP recorded matches the tag, and theversionCodeis one Android will accept. - With
--signed, which the job passes whenever the keystore secrets are present,apksigner verifyconfirms the signature.
That script takes the APK output directory and the expected version, so you can run the same check locally against your own build.
Google Play internal testing
The same android job also runs bundleRelease, checks the resulting AAB with android/scripts/verify-release-bundle.sh, and uploads it to the Google Play internal test track under the Dronecode Foundation account. The upload step runs only when the PLAY_SERVICE_ACCOUNT_JSON secret is present; it authenticates as a Google Cloud service account granted release-to-testing permission on the app in the Play Console. Prerelease tags upload like any other tag; rc builds are what the internal track is for. Promotion beyond internal testing is manual in the Play Console.
The AAB is not attached to the GitHub release. Google Play is its only destination, and Play App Signing re-signs it with the app signing key Google holds, so a Play install and a sideloaded APK carry different signatures and cannot upgrade over each other.
Anyone who sideloaded a self-signed APK from a release cut before signing landed (v0.3.0 and earlier) has to uninstall it once before an official signed build will install; release notes should carry that reminder until it stops being relevant.
Checking a release build before tagging
.github/workflows/android.yml has a release-build job that runs the same assembleRelease and bundleRelease tasks and the same verification scripts the release uses, including the signed path when the keystore secrets are present. It runs on pushes to main and on manual dispatch, and is skipped on pull requests to keep review turnaround fast.
Trigger it from a branch before tagging:
gh workflow run android.yml --ref my-branchThe push-to-main run of that job also warms the native build cache the release job reads, because a tag run restores caches from the default branch. A --ref my-branch dispatch does not warm it, since caches written on a branch are not visible to a later tag run.
The workflow's path filter covers the repository root src/, lib/, fonts/, models/, shaders/, and themes/ directories in addition to android/. The Android native library compiles source files out of the root src/ tree and its assets are symlinks to the root asset directories, so a desktop-side change can break the APK and has to trigger Android CI.