diff --git a/.github/scripts/generate-version.sh b/.github/scripts/generate-version.sh new file mode 100755 index 0000000000..d8563b7f53 --- /dev/null +++ b/.github/scripts/generate-version.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Validates VERSION_NAME and computes VERSION_CODE and FILE_NAME. +# +# Required environment variables: +# VERSION_NAME e.g. "2026.2.0" +# VERSION_SUFFIX e.g. "dev", "staging", "internal" +# RUN_ATTEMPT github.run_attempt — must be 1 (re-runs are not allowed) +# RUN_NUMBER github.run_number +# +# Outputs are appended to $GITHUB_ENV (or stdout if GITHUB_ENV is unset). + +set -euo pipefail + +# Re-runs are disabled because VERSION_CODE uses only run_number for its sequence slot. +if [ "${RUN_ATTEMPT}" -ne 1 ]; then + echo "ERROR: Workflow re-runs are not allowed for this build; trigger a new run instead" >&2 + exit 1 +fi + +# Validate VERSION_NAME format: YYYY.M.P (e.g. 2026.2.0) +if ! echo "$VERSION_NAME" | grep -qE '^[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}$'; then + echo "ERROR: VERSION_NAME '$VERSION_NAME' does not match required format YYYY.M.P" >&2 + exit 1 +fi + +IFS='.' read -r VERSION_YEAR VERSION_MINOR VERSION_PATCH <<< "$VERSION_NAME" +# Force base-10 to avoid octal misinterpretation (e.g. 08, 09) +VERSION_YEAR=$((10#$VERSION_YEAR)) +VERSION_MINOR=$((10#$VERSION_MINOR)) +VERSION_PATCH=$((10#$VERSION_PATCH)) + +if [ "$VERSION_YEAR" -lt 2020 ] || [ "$VERSION_YEAR" -gt 2099 ]; then + echo "ERROR: Version year $VERSION_YEAR is out of supported range (2020-2099)" >&2 + exit 1 +fi + +if [ "$VERSION_MINOR" -gt 99 ] || [ "$VERSION_PATCH" -gt 99 ]; then + echo "ERROR: Minor ($VERSION_MINOR) and patch ($VERSION_PATCH) must each be 0-99" >&2 + exit 1 +fi + +VERSION_CODE_CALCULATED=$(( (VERSION_YEAR - 2000) * 10000000 + VERSION_MINOR * 100000 + VERSION_PATCH * 1000 + RUN_NUMBER )) + +if [ "$VERSION_CODE_CALCULATED" -gt 2100000000 ]; then + echo "ERROR: VERSION_CODE $VERSION_CODE_CALCULATED exceeds Android max 2100000000" >&2 + exit 1 +fi + +DEST="${GITHUB_ENV:-/dev/stdout}" +echo "VERSION_CODE=$VERSION_CODE_CALCULATED" >> "$DEST" +echo "VERSION_BUILD=${RUN_NUMBER}.${RUN_ATTEMPT}" >> "$DEST" +echo "FILE_NAME=${VERSION_NAME}+${VERSION_SUFFIX}.${RUN_NUMBER}" >> "$DEST" diff --git a/.github/scripts/test-generate-version.sh b/.github/scripts/test-generate-version.sh new file mode 100755 index 0000000000..dde14864cd --- /dev/null +++ b/.github/scripts/test-generate-version.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# Tests for generate-version.sh +# +# Run from any directory: bash .github/scripts/test-generate-version.sh +# Exit code 0 = all tests passed, non-zero = one or more failures. + +set -uo pipefail + +SCRIPT="$(cd "$(dirname "$0")" && pwd)/generate-version.sh" +PASS=0 +FAIL=0 + +# --------------------------------------------------------------------------- +# Test runner helpers +# --------------------------------------------------------------------------- + +_run() { + # _run VERSION_NAME RUN_ATTEMPT RUN_NUMBER VERSION_SUFFIX + local env_file + env_file="$(mktemp "${TMPDIR:-/tmp}/gen_version_test.XXXXXX")" + _OUTPUT=$(VERSION_NAME="$1" RUN_ATTEMPT="$2" RUN_NUMBER="$3" VERSION_SUFFIX="$4" \ + GITHUB_ENV="$env_file" bash "$SCRIPT" 2>&1) + _EXIT=$? + _ENV_CONTENT="$(cat "$env_file" 2>/dev/null || true)" + rm -f "$env_file" +} + +_get_env() { + # Extract a key's value written to GITHUB_ENV + echo "$_ENV_CONTENT" | grep "^${1}=" | cut -d= -f2- +} + +assert_success() { + # assert_success LABEL EXPECTED_CODE EXPECTED_BUILD EXPECTED_FILE VERSION_NAME RUN_ATTEMPT RUN_NUMBER VERSION_SUFFIX + local label="$1" expected_code="$2" expected_build="$3" expected_file="$4" + shift 4 + _run "$@" + + if [ "$_EXIT" -ne 0 ]; then + echo "FAIL [$label]: expected success but got exit $_EXIT — $_OUTPUT" + FAIL=$(( FAIL + 1 )) + return + fi + + local actual_code actual_build actual_file + actual_code="$(_get_env VERSION_CODE)" + actual_build="$(_get_env VERSION_BUILD)" + actual_file="$(_get_env FILE_NAME)" + + local ok=1 + if [ "$actual_code" != "$expected_code" ]; then + echo "FAIL [$label]: VERSION_CODE expected=$expected_code actual=$actual_code" + ok=0 + fi + if [ "$actual_build" != "$expected_build" ]; then + echo "FAIL [$label]: VERSION_BUILD expected=$expected_build actual=$actual_build" + ok=0 + fi + if [ "$actual_file" != "$expected_file" ]; then + echo "FAIL [$label]: FILE_NAME expected=$expected_file actual=$actual_file" + ok=0 + fi + + if [ "$ok" -eq 1 ]; then + echo "PASS [$label]" + PASS=$(( PASS + 1 )) + else + FAIL=$(( FAIL + 1 )) + fi +} + +assert_failure() { + # assert_failure LABEL EXPECTED_ERROR_SUBSTRING VERSION_NAME RUN_ATTEMPT RUN_NUMBER VERSION_SUFFIX + local label="$1" expected_msg="$2" + shift 2 + _run "$@" + + if [ "$_EXIT" -eq 0 ]; then + echo "FAIL [$label]: expected failure but script succeeded (output: $_OUTPUT)" + FAIL=$(( FAIL + 1 )) + return + fi + + if ! echo "$_OUTPUT" | grep -qF "$expected_msg"; then + echo "FAIL [$label]: expected error containing '$expected_msg', got: $_OUTPUT" + FAIL=$(( FAIL + 1 )) + return + fi + + echo "PASS [$label]" + PASS=$(( PASS + 1 )) +} + +# --------------------------------------------------------------------------- +# Valid cases — formula: (year-2000)*10_000_000 + minor*100_000 + patch*1_000 + run_number +# --------------------------------------------------------------------------- + +# Standard release: 2026.2.0, run 345 +# = 26*10_000_000 + 2*100_000 + 0 + 345 = 260_200_345 +assert_success "standard release" \ + "260200345" "345.1" "2026.2.0+dev.345" \ + "2026.2.0" 1 345 "dev" + +# With non-zero patch: 2026.2.1, run 345 +# = 260_000_000 + 200_000 + 1_000 + 345 = 260_201_345 +assert_success "non-zero patch" \ + "260201345" "345.1" "2026.2.1+staging.345" \ + "2026.2.1" 1 345 "staging" + +# Octal edge case — leading zeros in month/patch (08, 09 must not be interpreted as octal) +# 2026.08.09, run 1 → (26)*10M + 8*100K + 9*1K + 1 = 260_809_001 +assert_success "octal edge case 08.09" \ + "260809001" "1.1" "2026.08.09+dev.1" \ + "2026.08.09" 1 1 "dev" + +# Another octal edge: 2026.09.08, run 1 → 260_000_000 + 900_000 + 8_000 + 1 = 260_908_001 +assert_success "octal edge case 09.08" \ + "260908001" "1.1" "2026.09.08+dev.1" \ + "2026.09.08" 1 1 "dev" + +# Double-digit minor: 2026.21.0, run 50 → 260_000_000 + 2_100_000 + 0 + 50 = 262_100_050 +assert_success "double-digit minor" \ + "262100050" "50.1" "2026.21.0+internal.50" \ + "2026.21.0" 1 50 "internal" + +# Double-digit patch: 2026.2.10, run 50 → 260_000_000 + 200_000 + 10_000 + 50 = 260_210_050 +assert_success "double-digit patch" \ + "260210050" "50.1" "2026.2.10+dev.50" \ + "2026.2.10" 1 50 "dev" + +# Double-digit minor and patch: 2026.21.10, run 50 → 260_000_000 + 2_100_000 + 10_000 + 50 = 262_110_050 +assert_success "double-digit minor and patch" \ + "262110050" "50.1" "2026.21.10+dev.50" \ + "2026.21.10" 1 50 "dev" + +# Minimum valid year: 2020.1.0, run 1 → 20*10M + 100_000 + 0 + 1 = 200_100_001 +assert_success "minimum year boundary" \ + "200100001" "1.1" "2020.1.0+dev.1" \ + "2020.1.0" 1 1 "dev" + +# Maximum valid values: 2099.99.99, run 999 → 99*10M + 9_900_000 + 99_000 + 999 = 999_999_999 +assert_success "maximum valid values" \ + "999999999" "999.1" "2099.99.99+dev.999" \ + "2099.99.99" 1 999 "dev" + +# run_number 1000 — no longer wraps, adds directly +# 2026.2.0, run 1000 → 260_200_000 + 1_000 = 260_201_000 +assert_success "run_number 1000 does not wrap" \ + "260201000" "1000.1" "2026.2.0+dev.1000" \ + "2026.2.0" 1 1000 "dev" + +# run_number 2000 — adds directly +# 2026.2.0, run 2000 → 260_200_000 + 2_000 = 260_202_000 +assert_success "run_number 2000 does not wrap" \ + "260202000" "2000.1" "2026.2.0+dev.2000" \ + "2026.2.0" 1 2000 "dev" + +# Large run_number: 5001 → adds directly +# 2026.2.0, run 5001 → 260_200_000 + 5_001 = 260_205_001 +assert_success "large run_number" \ + "260205001" "5001.1" "2026.2.0+dev.5001" \ + "2026.2.0" 1 5001 "dev" + +# run_number at 999 +assert_success "run_number at 999" \ + "260200999" "999.1" "2026.2.0+dev.999" \ + "2026.2.0" 1 999 "dev" + +# Patch at boundary 99: 2026.1.99, run 1 → 260_000_000 + 100_000 + 99_000 + 1 = 260_199_001 +assert_success "patch boundary 99" \ + "260199001" "1.1" "2026.1.99+dev.1" \ + "2026.1.99" 1 1 "dev" + +# Minor at boundary 99: 2026.99.0, run 1 → 260_000_000 + 9_900_000 + 0 + 1 = 269_900_001 +assert_success "minor boundary 99" \ + "269900001" "1.1" "2026.99.0+dev.1" \ + "2026.99.0" 1 1 "dev" + +# All components at single digit minimums: 2020.0.0, run 1 → 200_000_001 +assert_success "minor and patch at 0" \ + "200000001" "1.1" "2020.0.0+dev.1" \ + "2020.0.0" 1 1 "dev" + +# --------------------------------------------------------------------------- +# Invalid cases — re-run detection +# --------------------------------------------------------------------------- + +assert_failure "re-run attempt=2" "re-runs are not allowed" \ + "2026.2.0" 2 345 "dev" + +assert_failure "re-run attempt=3" "re-runs are not allowed" \ + "2026.2.0" 3 1 "dev" + +# --------------------------------------------------------------------------- +# Invalid cases — VERSION_NAME format +# --------------------------------------------------------------------------- + +assert_failure "missing patch component" "does not match required format" \ + "2026.2" 1 1 "dev" + +assert_failure "2-digit year" "does not match required format" \ + "26.2.0" 1 1 "dev" + +assert_failure "3-digit year" "does not match required format" \ + "202.2.0" 1 1 "dev" + +assert_failure "extra 4th component" "does not match required format" \ + "2026.2.0.1" 1 1 "dev" + +assert_failure "alphabetic minor" "does not match required format" \ + "2026.a.0" 1 1 "dev" + +assert_failure "alphabetic patch" "does not match required format" \ + "2026.2.b" 1 1 "dev" + +assert_failure "empty version name" "does not match required format" \ + "" 1 1 "dev" + +assert_failure "only dots" "does not match required format" \ + "..." 1 1 "dev" + +# 3-digit minor (100) is rejected by the regex before the range check +assert_failure "3-digit minor 100 rejected by format" "does not match required format" \ + "2026.100.0" 1 1 "dev" + +# 3-digit patch (100) is rejected by the regex before the range check +assert_failure "3-digit patch 100 rejected by format" "does not match required format" \ + "2026.1.100" 1 1 "dev" + +# --------------------------------------------------------------------------- +# Invalid cases — year out of range +# --------------------------------------------------------------------------- + +assert_failure "year below minimum (2019)" "out of supported range" \ + "2019.1.0" 1 1 "dev" + +assert_failure "year at 2000 (below minimum)" "out of supported range" \ + "2000.1.0" 1 1 "dev" + +assert_failure "year above maximum (2100)" "out of supported range" \ + "2100.1.0" 1 1 "dev" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +TOTAL=$(( PASS + FAIL )) +echo "" +echo "Results: $PASS/$TOTAL passed" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/.github/workflows/reusable-build-apk.yml b/.github/workflows/reusable-build-apk.yml index b920d9c235..aa06c6a01e 100644 --- a/.github/workflows/reusable-build-apk.yml +++ b/.github/workflows/reusable-build-apk.yml @@ -30,18 +30,12 @@ jobs: environment: ${{ inputs.build-environment }} env: - # VERSION_CODE: Unique version code using the sum of the current timestamp and run number. + # VERSION_CODE formula: (year-2000)*10_000_000 + minor*100_000 + patch*1_000 + run_number + # Re-runs are disabled: github.run_attempt must be 1. VERSION_CODE: "set in lower step" - # VERSION_NAME: Version name derived from the GitHub ref name after the final /. VERSION_NAME: ${{ inputs.version-name }} - # VERSION_SUFFIX: The build environment (e.g., dev, staging, internal). VERSION_SUFFIX: ${{ inputs.build-environment }} - # VERSION_BUILD: A unique build identifier combining the run number and attempt. - VERSION_BUILD: ${{ github.run_number }}.${{ github.run_attempt }} - # The final output file name. FILE_NAME: "set in lower step" - # The base floor version code. Please check the README for more information. - BASE_VERSION_CODE: 10000000 steps: - name: Checkout @@ -53,26 +47,11 @@ jobs: java-version: 21 distribution: 'temurin' - - name: Check workflow validity - if: ${{ github.run_attempt > 99 }} - run: | - echo "Run attempts exceeded 99. Please start a new workflow." - exit 1 - - - name: Check base version code - run: | - if [ $(( ${{ github.run_number }} * 100 )) -ge $BASE_VERSION_CODE ]; then - echo "Github workflows now exceeds the base version code..." - echo "BASE_VERSION_CODE=0" >> $GITHUB_ENV - fi - - - name: Compute version variables - run: | - echo "VERSION_CODE=$(($BASE_VERSION_CODE + ${{ github.run_number }} * 100 + ${{ github.run_attempt }}))" >> $GITHUB_ENV - - - name: Compute file name - run: | - echo "FILE_NAME=${{ env.VERSION_NAME }}+${{ env.VERSION_SUFFIX }}.${{ env.VERSION_BUILD }}" >> $GITHUB_ENV + - name: Validate version name and compute version code + env: + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_NUMBER: ${{ github.run_number }} + run: bash .github/scripts/generate-version.sh - name: Set up build files uses: ./.github/actions/setup-gradle-build-files diff --git a/.github/workflows/test-version-generation.yml b/.github/workflows/test-version-generation.yml new file mode 100644 index 0000000000..da1f8db572 --- /dev/null +++ b/.github/workflows/test-version-generation.yml @@ -0,0 +1,18 @@ +name: Test version generation + +on: + workflow_dispatch: + push: + paths: + - '.github/scripts/generate-version.sh' + - '.github/scripts/test-generate-version.sh' + +jobs: + test-version-generation: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run version generation tests + run: bash .github/scripts/test-generate-version.sh