Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/actions/build-android/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,20 @@ runs:
echo "Version Code: ${{ inputs.version_code }}"
echo "Build Type: ${{ inputs.build_type }}"

- name: Fetch tags for submodule
- name: Fetch tags and history for submodule
shell: bash
run: |
cd netbird
git fetch --tags
# build-android-lib.sh resolves the version by walking HEAD's ancestry
# back to the last release tag. actions/checkout clones submodules
# shallow, which fetches the tags but not the commits between HEAD and
# the tag, so without full history the walk comes up empty and the
# build silently falls back to a ci-<sha> version.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --unshallow --tags
else
git fetch --tags
fi

- name: Setup Java
uses: actions/setup-java@v4
Expand Down
43 changes: 40 additions & 3 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ on:

permissions:
contents: write
# Needed to read the run counts the version code is derived from.
actions: read

concurrency:
group: android-version-code-lock
cancel-in-progress: false

jobs:
build-release:
Expand Down Expand Up @@ -39,18 +45,49 @@ jobs:
echo "${{ secrets.GPLAY_KEYSTORE }}" | base64 -d > gplay.keystore
echo "NETBIRD_UPLOAD_STORE_FILE=$GITHUB_WORKSPACE/gplay.keystore" >> $GITHUB_ENV

# Must stay identical to the Compute version code step in the sibling
# workflow. build-release.yml and build-snapshot.yml feed the same Play
# Store listing, so they have to draw from one shared counter; a
# github.run_number sees only its own workflow's runs and would hand out a
# code the other already used. The concurrency group above keeps the two
# from computing at once. Renaming either workflow file resets the count
# GitHub keeps for it, which would send version codes backwards.
#
# A 404 means the workflow has never run and is not on the default branch
# yet, which is a true zero. Any other failure aborts the build: falling
# back to zero would mint a version code below already-published ones.
- name: Compute version code
id: version_code
env:
GH_TOKEN: ${{ github.token }}
run: |
adjusted=$(( ${{ github.run_number }} + 40 ))
echo "adjusted=$adjusted" >> $GITHUB_OUTPUT
count_runs() {
local runs
if runs=$(gh api "repos/${{ github.repository }}/actions/workflows/$1/runs?per_page=1" --jq '.total_count' 2>gh_err.txt); then
if ! [[ "$runs" =~ ^[0-9]+$ ]]; then
echo "::error::unexpected run count for $1: '$runs'" >&2
return 1
fi
echo "$runs"
elif grep -q 'HTTP 404' gh_err.txt; then
echo 0
else
echo "::error::failed to fetch run count for $1: $(cat gh_err.txt)" >&2
return 1
fi
}
release_runs=$(count_runs build-release.yml)
snapshot_runs=$(count_runs build-snapshot.yml)
version_code=$((release_runs + snapshot_runs + 40))
echo "Release runs: $release_runs, snapshot runs: $snapshot_runs -> version_code=$version_code"
echo "version_code=$version_code" >> "$GITHUB_OUTPUT"

- name: Build Android
id: build
uses: ./.github/actions/build-android
with:
version_name: ${{ steps.version.outputs.version_name }}
version_code: ${{ steps.version_code.outputs.adjusted }}
version_code: ${{ steps.version_code.outputs.version_code }}
build_type: release

- name: Upload files to existing release
Expand Down
100 changes: 100 additions & 0 deletions .github/workflows/build-snapshot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: build snapshot

on:
workflow_dispatch:

permissions:
contents: read
actions: read

concurrency:
group: android-version-code-lock
cancel-in-progress: false

jobs:
build-snapshot:
runs-on: ubuntu-latest
environment: android-release
env:
NETBIRD_UPLOAD_KEY_ALIAS: ${{ secrets.NETBIRD_UPLOAD_KEY_ALIAS }}
NETBIRD_UPLOAD_KEY_PASSWORD: ${{ secrets.NETBIRD_UPLOAD_KEY_PASSWORD }}
NETBIRD_UPLOAD_STORE_PASSWORD: ${{ secrets.NETBIRD_UPLOAD_STORE_PASSWORD }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive

# Reaches the management server as the peer's ui_version and is shown on
# the app's about screen, so it has to say at a glance that this build was
# signed and handed out rather than released. build-debug.yml already
# claims the ci- prefix, and sharing it would make an unsigned PR build
# and a signed hand-out indistinguishable in the peer list.
- name: Get version name
id: version
run: |
SHORT_GIT_SHA=$(git rev-parse --short HEAD)
echo "version_name=snapshot-${SHORT_GIT_SHA}" >> "$GITHUB_OUTPUT"

# Must stay identical to the Compute version code step in the sibling
# workflow. build-release.yml and build-snapshot.yml feed the same Play
# Store listing, so they have to draw from one shared counter; a
# github.run_number sees only its own workflow's runs and would hand out a
# code the other already used. The concurrency group above keeps the two
# from computing at once. Renaming either workflow file resets the count
# GitHub keeps for it, which would send version codes backwards.
#
# A 404 means the workflow has never run and is not on the default branch
# yet, which is a true zero. Any other failure aborts the build: falling
# back to zero would mint a version code below already-published ones.
- name: Compute version code
id: version_code
env:
GH_TOKEN: ${{ github.token }}
run: |
count_runs() {
local runs
if runs=$(gh api "repos/${{ github.repository }}/actions/workflows/$1/runs?per_page=1" --jq '.total_count' 2>gh_err.txt); then
if ! [[ "$runs" =~ ^[0-9]+$ ]]; then
echo "::error::unexpected run count for $1: '$runs'" >&2
return 1
fi
echo "$runs"
elif grep -q 'HTTP 404' gh_err.txt; then
echo 0
else
echo "::error::failed to fetch run count for $1: $(cat gh_err.txt)" >&2
return 1
fi
}
release_runs=$(count_runs build-release.yml)
snapshot_runs=$(count_runs build-snapshot.yml)
version_code=$((release_runs + snapshot_runs + 40))
echo "Release runs: $release_runs, snapshot runs: $snapshot_runs -> version_code=$version_code"
echo "version_code=$version_code" >> "$GITHUB_OUTPUT"

- name: Write google-services.json
run: |
echo "${{ secrets.GOOGLE_JSON }}" | base64 -d > app/google-services.json

- name: Write keystore file
run: |
echo "${{ secrets.GPLAY_KEYSTORE }}" | base64 -d > gplay.keystore
echo "NETBIRD_UPLOAD_STORE_FILE=$GITHUB_WORKSPACE/gplay.keystore" >> "$GITHUB_ENV"

- name: Build Android
id: build
uses: ./.github/actions/build-android
with:
version_name: ${{ steps.version.outputs.version_name }}
version_code: ${{ steps.version_code.outputs.version_code }}
build_type: release

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ steps.version.outputs.version_name }}
path: |
${{ steps.build.outputs.apk_path }}
${{ steps.build.outputs.bundle_path }}
retention-days: 14
59 changes: 41 additions & 18 deletions build-android-lib.sh
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
#!/bin/bash
# Script to build NetBird mobile bindings using gomobile
# Usage: ./script.sh [version]
# - If a version is provided, it will be used (with leading 'v' stripped if present).
# - If no version is provided:
# * Uses the latest Git tag if available (with leading 'v' stripped if present).
# * Otherwise, defaults to "dev-<short-hash>".
# - When running in GitHub Actions, uses "ci-<short-hash>" instead of "dev-<short-hash>".
#
# Version resolution (first match wins):
# 1. explicit argument -> the argument ('v' prefix stripped)
# 2. local build (any HEAD) -> dev-<sha>
# 3. CI, HEAD on a release tag -> that tag, e.g. 0.77.0
# 4. CI, commits on top of a tag -> 0.77.0+<sha>
# 5. CI, no reachable tag -> ci-<sha>
#
# The base tag is the last stable release tag (vX.Y.Z, no pre-release) found
# walking back HEAD's ancestry in the netbird submodule — the last tag on this
# branch, not the newest tag in the repository. <sha> is the submodule commit.

set -euo pipefail

app_path=$(pwd)

# Stable release tags only ("v" + digits, no pre-release suffix): a pre-release
# base such as "0.75.0-rc.2" would land in SemVer pre-release position, which
# the management server compares differently from a plain release.
readonly RELEASE_TAG_MATCH='v[0-9]*'
readonly RELEASE_TAG_EXCLUDE='*-*'

# Normalize semantic versions to drop a leading 'v' (e.g., v1.2.3 -> 1.2.3).
# Only strips if the string starts with 'v' followed by a digit, so it won't affect
# dev/ci strings or other non-semver values.
Expand All @@ -22,33 +34,44 @@ normalize_version() {
echo "$ver"
}

describe_release_tag() {
git describe --tags "$@" --match "$RELEASE_TAG_MATCH" --exclude "$RELEASE_TAG_EXCLUDE" 2>/dev/null || true
}

get_version() {
if [ -n "${1:-}" ]; then
normalize_version "$1"
return
fi

# Try to get an exact tag
local tag
tag=$(git describe --tags --exact-match 2>/dev/null || true)
local short_hash
short_hash=$(git rev-parse --short HEAD)

if [ "${GITHUB_ACTIONS:-}" != "true" ]; then
echo "dev-$short_hash"
return
fi

local tag
tag=$(describe_release_tag --exact-match)
if [ -n "$tag" ]; then
normalize_version "$tag"
return
fi

# Fallback to "<prefix>-<short-hash>"
local short_hash
short_hash=$(git rev-parse --short HEAD)

local new_version
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
new_version="ci-$short_hash"
else
new_version="dev-$short_hash"
# Walks HEAD's ancestry, so this is the last release tag on this branch,
# not the most recently created tag in the repository.
tag=$(describe_release_tag --abbrev=0)
if [ -n "$tag" ]; then
echo "$(normalize_version "$tag")+$short_hash"
return
fi

echo "$new_version"
echo "WARNING: no release tag reachable from HEAD; using ci-$short_hash" >&2
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
echo "WARNING: the submodule is a shallow clone; the tag lookup needs full history" >&2
fi
echo "ci-$short_hash"
}

cd netbird
Expand Down
52 changes: 52 additions & 0 deletions docs/versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Android client build and versioning

## The three workflows

### `build-debug.yml` — the CI gate

Runs automatically on every pull request and on every push to `main`. It has
three jobs: `build-debug` produces the AAR and a debug APK/AAB, then
`unit-tests` and `instrumented-tests` download that AAR and run against it, the
latter on an emulator.

Its product is a pass/fail signal, plus the `netbird-aar` artifact that the two
test jobs consume. The debug APK it uploads is a convenience for humans; nothing
in CI reads it.

### `build-release.yml` — the published release

Triggered by `release: published`, which fires for pre-releases too. It builds
the signed APK and AAB and attaches them to the GitHub release. This is the
source of anything that goes to the Play Store.

Release candidates run through this workflow: they are ordinary GitHub
pre-releases tagged `vX.Y.Z-rc.N` (for example `v0.6.0-rc.1`, `v0.3.3-rc.2`).

### `build-snapshot.yml` — the hand-distributed build

Manually dispatched. Produces a release-signed build from an arbitrary commit
with no tag behind it, uploaded as a 14-day artifact rather than published.

Use it when someone needs a real, installable build of work in progress and you
do not want a public pre-release for it. Every run consumes a version code from
the shared counter, so it is not something to run per pull request.

---

## Differences

| | `build-debug` | `build-release` | `build-snapshot` |
|---|---|---|---|
| Trigger | `pull_request`, `push` → `main` | `release: published` | `workflow_dispatch` |
| Build type | debug | release | release |
| **Version name** | `ci-<sha>` | the release tag verbatim (`v0.5.0`, `v0.6.0-rc.1`) | `snapshot-<sha>` |
| **Version code** | `9999` (fallback from `version.properties`) | `release_runs + snapshot_runs + 40` | `release_runs + snapshot_runs + 40` |
| **Signing key** | default Android debug keystore | Play upload keystore (`gplay.keystore`) | Play upload keystore (`gplay.keystore`) |
| **Firebase Crashlytics + Analytics** | **absent** | present | present |
| Runs tests | yes (unit + instrumented) | no | no |
| Artifact | `debug-artifacts-<name>`, 3 days; `netbird-aar`, 1 day | attached to the GitHub release | `snapshot-<sha>`, 14 days |
| Permissions | `contents: read` | `contents: write`, `actions: read` | `contents: read`, `actions: read` |
| Concurrency group | none | `android-version-code-lock` | `android-version-code-lock` |

`<sha>` is the short commit hash of the `android-client` repository, not of the
submodule.
Loading