diff --git a/.github/actions/build-android/action.yml b/.github/actions/build-android/action.yml index f9579e6a..3a4e7504 100644 --- a/.github/actions/build-android/action.yml +++ b/.github/actions/build-android/action.yml @@ -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- 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 diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 26ea5f9a..ec7d146a 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -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: @@ -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 diff --git a/.github/workflows/build-snapshot.yml b/.github/workflows/build-snapshot.yml new file mode 100644 index 00000000..1df097f3 --- /dev/null +++ b/.github/workflows/build-snapshot.yml @@ -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 diff --git a/build-android-lib.sh b/build-android-lib.sh index ea10750d..39f244a6 100755 --- a/build-android-lib.sh +++ b/build-android-lib.sh @@ -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-". -# - When running in GitHub Actions, uses "ci-" instead of "dev-". +# +# Version resolution (first match wins): +# 1. explicit argument -> the argument ('v' prefix stripped) +# 2. local build (any HEAD) -> dev- +# 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+ +# 5. CI, no reachable tag -> ci- +# +# 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. 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. @@ -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 "-" - 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 diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 00000000..d92b450a --- /dev/null +++ b/docs/versioning.md @@ -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-` | the release tag verbatim (`v0.5.0`, `v0.6.0-rc.1`) | `snapshot-` | +| **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-`, 3 days; `netbird-aar`, 1 day | attached to the GitHub release | `snapshot-`, 14 days | +| Permissions | `contents: read` | `contents: write`, `actions: read` | `contents: read`, `actions: read` | +| Concurrency group | none | `android-version-code-lock` | `android-version-code-lock` | + +`` is the short commit hash of the `android-client` repository, not of the +submodule.