diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0be1900e..78e6e080 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -43,3 +43,4 @@ Detailed guidelines are in `.claude/rules/`: - `pull-requests.md` — PR title and description conventions - `agent-instructions.md` — Sub-agent delegation and critical thinking - `screenshots.md` — Play Store screenshot pipeline, commands, adding new screens +- `release.md` — Release workflow (`Release prepare` dispatch), inputs, channel mapping, rollback diff --git a/.claude/rules/release.md b/.claude/rules/release.md new file mode 100644 index 00000000..288bacb1 --- /dev/null +++ b/.claude/rules/release.md @@ -0,0 +1,63 @@ +# Release Process + +Releases are cut via the **Release prepare** workflow (`.github/workflows/release-prepare.yml`). It bumps `version.properties` and `VERSION`, commits to `main`, tags `v`, pushes atomically, and dispatches `release-tag.yml` which builds, signs, and uploads. + +## Dispatch + +```bash +# Plan only — no commit, no tag, no push. +gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true + +# Real cut. +gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false +``` + +After `dry_run=false`: Job 1 computes + writes the summary, Job 2 pauses for `foss-production` environment approval, then commits/tags/pushes/dispatches. `release-tag.yml` then runs `validate-tag` and the existing `release-github` + `release-gplay` jobs (both with their own environment approvals). + +## Inputs + +| Input | Default | Notes | +|---|---|---| +| `bump_kind` | `build` | `build` \| `patch` \| `minor` \| `major` | +| `version_type` | `keep-current` | Preserves current `rc`/`beta`. Set explicitly to switch. | +| `version_override` | empty | e.g. `5.1.2-rc0`. Bypasses bump_kind/version_type. | +| `expected_current` | empty | Optional: fail if `version.properties` ≠ this. Useful for tight coordination. | +| `dry_run` | `true` | Default is plan-only. | + +Bump rules: `build` increments build; `patch`/`minor`/`major` zero everything to the right of the bumped field. All numeric fields bounded `0..99` (the `versionCode` formula collapses at ≥100). + +## Local + +```bash +./tools/release/bump.sh --mode=plan --bump-kind=build --version-type=keep-current +./tools/release/bump.sh --mode=check +bats tools/release/bump.bats +``` + +## Channel mapping + +| Tag suffix | FOSS APK | GitHub release | Fastlane lane | Play track | Rollout | +|---|---|---|---|---|---| +| `-beta*` | `assembleFossBeta` | pre-release | `beta` | `beta` | 10% | +| `-rc*` (or anything else) | `assembleFossRelease` | full release | `production` | **`beta`** | 10% | + +`lane :production` in `Fastfile` uploads to Play's **beta** track at 10% — manually promoted to production via Play Console. + +## Rollback + +| Stage reached | Steps | +|---|---| +| Bump on `main`, downstream not started | `git push origin :refs/tags/v`, `git revert `, push | +| GitHub release created | Above + `gh release delete v --yes --cleanup-tag` | +| Play upload completed | Above + halt rollout in Play Console (or `bundle exec fastlane supply --track beta --rollout 0 --version-code `) | +| Job 2 ran but downstream rejected at env approval | Treat as first row — bump+tag are public on `main` regardless of downstream outcome | + +`bump.sh` enforces strict `versionCode` monotonicity, so re-using a code is impossible without manually editing `version.properties`. + +## Defense in depth + +`release-tag.yml` includes `validate-tag` which: (1) regex-checks `github.ref_name`, (2) runs `bump.sh --mode=check`, (3) asserts the parsed name matches the tag. Manual `gh workflow run release-tag.yml --ref vfoo` or hand-pushed tags fail before any build. + +## Stuck-dispatch recovery + +If Job 2's atomic push lands but `gh workflow run release-tag.yml` fails (rare — Job 1's auth precheck should prevent it), the tag is public but no pipeline runs. Re-dispatch: `gh workflow run release-tag.yml --ref v -f dry_run=false`. \ No newline at end of file diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 00000000..512b3f81 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,227 @@ +name: Release prepare + +on: + workflow_dispatch: + inputs: + bump_kind: + description: 'How to bump the version (ignored if version_override is set)' + type: choice + options: [build, patch, minor, major] + default: build + version_type: + description: 'Channel for the new version (keep-current preserves current type)' + type: choice + options: [keep-current, rc, beta] + default: keep-current + version_override: + description: 'Explicit version, e.g. 5.1.2-rc0 (overrides bump_kind/version_type)' + type: string + default: '' + expected_current: + description: 'Optional safety check: fail if current version.properties does not match (e.g. 5.1.1-rc0)' + type: string + default: '' + dry_run: + description: 'When true: compute and validate only. When false: commit, tag, push, dispatch release-tag.yml.' + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: release-prepare-main + cancel-in-progress: false + +jobs: + compute-and-validate: + name: Compute and validate + runs-on: ubuntu-22.04 + permissions: + contents: read + actions: read + outputs: + new_name: ${{ steps.plan.outputs.new_name }} + new_code: ${{ steps.plan.outputs.new_code }} + current_name: ${{ steps.plan.outputs.current_name }} + env: + GH_TOKEN: ${{ github.token }} + INPUT_BUMP_KIND: ${{ inputs.bump_kind }} + INPUT_VERSION_TYPE: ${{ inputs.version_type }} + INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }} + INPUT_EXPECTED_CURRENT: ${{ inputs.expected_current }} + steps: + - name: Guard ref must be main + run: | + if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then + echo "Must dispatch from main, got ${GITHUB_REF}" >&2 + exit 1 + fi + + - name: Checkout main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Verify gh auth and dispatch capability + run: gh workflow view release-tag.yml > /dev/null + + - name: Compute and validate + id: plan + run: | + set -euo pipefail + args=("--mode=plan") + if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then + args+=("--version-override=${INPUT_VERSION_OVERRIDE}") + else + args+=("--bump-kind=${INPUT_BUMP_KIND}") + args+=("--version-type=${INPUT_VERSION_TYPE}") + fi + if [[ -n "${INPUT_EXPECTED_CURRENT}" ]]; then + args+=("--expected-current=${INPUT_EXPECTED_CURRENT}") + fi + ./tools/release/bump.sh "${args[@]}" | tee plan.txt + { + grep -E '^current_name=' plan.txt + grep -E '^new_name=' plan.txt + grep -E '^new_code=' plan.txt + } >> "$GITHUB_OUTPUT" + + - name: Tag collision check (local + remote) + env: + NEW_NAME: ${{ steps.plan.outputs.new_name }} + run: | + set -euo pipefail + if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then + echo "Local tag v${NEW_NAME} already exists" >&2 + exit 1 + fi + if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then + echo "Remote tag v${NEW_NAME} already exists" >&2 + exit 1 + fi + + - name: Write step summary + env: + CURRENT_NAME: ${{ steps.plan.outputs.current_name }} + NEW_NAME: ${{ steps.plan.outputs.new_name }} + NEW_CODE: ${{ steps.plan.outputs.new_code }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + { + echo "## Release plan" + echo + echo "| | |" + echo "|---|---|" + echo "| Current | \`${CURRENT_NAME}\` |" + echo "| New | \`${NEW_NAME}\` (code ${NEW_CODE}) |" + echo "| Tag | \`v${NEW_NAME}\` |" + echo "| Dry run | \`${DRY_RUN}\` |" + echo + echo "### bump.sh output" + echo + echo '```' + cat plan.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + push-and-dispatch: + name: Push and dispatch + needs: compute-and-validate + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-22.04 + environment: foss-production + permissions: + contents: write + actions: write + env: + GH_TOKEN: ${{ github.token }} + INPUT_BUMP_KIND: ${{ inputs.bump_kind }} + INPUT_VERSION_TYPE: ${{ inputs.version_type }} + INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }} + NEW_NAME: ${{ needs.compute-and-validate.outputs.new_name }} + NEW_CODE: ${{ needs.compute-and-validate.outputs.new_code }} + CURRENT_NAME_AT_PLAN: ${{ needs.compute-and-validate.outputs.current_name }} + steps: + - name: Checkout main with credentials + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + with: + ref: main + fetch-depth: 0 + persist-credentials: true + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Re-validate after approval wait + run: | + set -euo pipefail + ./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}" + + - name: Re-check tag collision (state may have moved during approval) + run: | + set -euo pipefail + if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then + echo "Local tag v${NEW_NAME} already exists" >&2 + exit 1 + fi + if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then + echo "Remote tag v${NEW_NAME} already exists" >&2 + exit 1 + fi + + - name: Apply bump + run: | + set -euo pipefail + args=("--mode=write" "--expected-current=${CURRENT_NAME_AT_PLAN}") + if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then + args+=("--version-override=${INPUT_VERSION_OVERRIDE}") + else + args+=("--bump-kind=${INPUT_BUMP_KIND}") + args+=("--version-type=${INPUT_VERSION_TYPE}") + fi + ./tools/release/bump.sh "${args[@]}" + + - name: Verify post-write state matches plan + run: | + set -euo pipefail + ./tools/release/bump.sh --mode=check --expected-current="${NEW_NAME}" + + - name: Configure git identity + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + - name: Commit and tag + run: | + set -euo pipefail + git add version.properties VERSION + git commit -m "Release: ${NEW_NAME}" + git tag -a "v${NEW_NAME}" -m "Release v${NEW_NAME}" + + - name: Atomic push (commit + tag) + run: | + set -euo pipefail + git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}" + + - name: Dispatch release-tag.yml + run: | + set -euo pipefail + gh workflow run release-tag.yml --ref "v${NEW_NAME}" -f dry_run=false + + - name: Write step summary + run: | + set -euo pipefail + { + echo "## Released" + echo + echo "| | |" + echo "|---|---|" + echo "| Tag | \`v${NEW_NAME}\` |" + echo "| Version code | \`${NEW_CODE}\` |" + echo "| Bump commit | on \`main\` |" + echo "| Downstream | dispatched \`release-tag.yml\` |" + echo + echo "Watch the [Tagged releases](../../actions/workflows/release-tag.yml) workflow for the build + upload." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index a6190df3..812c338c 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -14,8 +14,50 @@ on: permissions: contents: read +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + jobs: + validate-tag: + name: Validate tag + runs-on: ubuntu-22.04 + steps: + - name: Check tag-name format + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + if [[ ! "${REF_NAME}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}-(rc|beta)[0-9]{1,2}$ ]]; then + echo "Tag '${REF_NAME}' does not match v" >&2 + echo "Releases must be cut via the 'Release prepare' workflow." >&2 + exit 1 + fi + + - name: Checkout source code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Verify version.properties matches tag + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + # Strip leading 'v' to get the bare version name. + tag_name="${REF_NAME#v}" + # bump.sh in check mode emits current_name=... + parsed=$(./tools/release/bump.sh --mode=check) + current_name=$(echo "$parsed" | grep -E '^current_name=' | cut -d= -f2) + if [[ "$current_name" != "$tag_name" ]]; then + echo "Tag '${REF_NAME}' does not match version.properties name '$current_name'" >&2 + echo "version.properties + VERSION must equal the tag — releases must be cut via 'Release prepare'." >&2 + exit 1 + fi + release-github: + needs: validate-tag name: Create GitHub release permissions: contents: write @@ -91,6 +133,7 @@ jobs: run: gh workflow run pages.yml --ref main release-gplay: + needs: validate-tag name: Create Google Play release runs-on: ubuntu-22.04 environment: gplay-production diff --git a/release.sh b/release.sh deleted file mode 100755 index fff0f3bb..00000000 --- a/release.sh +++ /dev/null @@ -1,463 +0,0 @@ -#!/bin/bash -# Based on -# * https://gist.github.com/jv-k/703e79306554c26a65a7cfdb9ca119c6 -# * https://github.com/jv-k/ver-bump - -# █▄▄ █░█ █▀▄▀█ █▀█ ▄▄ █░█ █▀▀ █▀█ █▀ █ █▀█ █▄░█ -# █▄█ █▄█ █░▀░█ █▀▀ ░░ ▀▄▀ ██▄ █▀▄ ▄█ █ █▄█ █░▀█ -# -# -# Description: -# - This script automates bumping the git software project's version using automation. - -# - It does several things that are typically required for releasing a Git repository, like git tagging, -# automatic updating of CHANGELOG.md, and incrementing the version number in various JSON files. - -# - Increments / suggests the current software project's version number -# - Adds a Git tag, named after the chosen version number -# - Updates CHANGELOG.md -# - Updates VERSION file -# - Commits files to a new branch -# - Pushes to remote (optionally) -# - Updates "version" : "x.x.x" tag in JSON files if [-v file1 -v file2...] argument is supplied. -# -# Usage: -# ./bump-version.sh [-v ] [-m ] [-j ] [-j ].. [-n] [-p] [-b] [-h] -# -# Options: -# -v Specify a manual version number -# -m Custom release message. -# -f Update version number inside JSON files. -# * For multiple files, add a separate -f option for each one, -# * For example: ./bump-version.sh -f src/plugin/package.json -f composer.json -# -p Push commits to remote repository, eg `-p origin` -# -n Don't perform a commit automatically. -# * You may want to do that yourself, for example. -# -b Don't create automatic `release-` branch -# -h Show help message. - -# -# Detailed notes: -# – The contents of the `VERSION` file which should be a semantic version number such as "1.2.3" -# or even "1.2.3-beta+001.ab" -# -# – It pulls a list of changes from git history & prepends to a file called CHANGELOG.md -# under the title of the new version # number, allows the user to review and update the changelist -# -# – Creates a Git tag with the version number -# -# - Creates automatic `release-` branch -# -# – Commits the new version to the current repository -# -# – Optionally pushes the commit to remote repository -# -# – Make sure to set execute permissions for the script, eg `$ chmod 755 bump-version.sh` -# -# Credits: -# – https://github.com/jv-k/bump-version -# -# - Inspired by the scripts from @pete-otaqui and @mareksuscak -# https://gist.github.com/pete-otaqui/4188238 -# https://gist.github.com/mareksuscak/1f206fbc3bb9d97dec9c -# - -NOW="$(date +'%B %d, %Y')" - -# ANSI/VT100 colours -YELLOW='\033[1;33m' -LIGHTYELLOW='\033[0;33m' -RED='\033[0;31m' -LIGHTRED='\033[1;31m' -GREEN='\033[0;32m' -LIGHTGREEN='\033[1;32m' -BLUE='\033[0;34m' -LIGHTBLUE='\033[1;34m' -PURPLE='\033[0;35m' -LIGHTPURPLE='\033[1;35m' -CYAN='\033[0;36m' -LIGHTCYAN='\033[1;36m' -WHITE='\033[1;37m' -LIGHTGRAY='\033[0;37m' -DARKGRAY='\033[1;30m' -BOLD="\033[1m" -INVERT="\033[7m" -RESET='\033[0m' - -# Default options -FLAG_JSON="false" -FLAG_PUSH="false" - -I_OK="✅" -I_STOP="🚫" -I_ERROR="❌" -I_END="👋🏻" - -S_NORM="${WHITE}" -S_LIGHT="${LIGHTGRAY}" -S_NOTICE="${GREEN}" -S_QUESTION="${YELLOW}" -S_WARN="${LIGHTRED}" -S_ERROR="${RED}" - -V_SUGGEST="0.1.2-rc5" # This is suggested in case VERSION file or user supplied version via -v is missing - -V_MAJOR="" # 0 -V_MINOR="" # 1 -V_PATCH="" # 2 -V_BUILD_TYPE="" # rc -V_BUILD_COUNTER="" # 5 -V_NAME="" -V_CODE="" - -SCRIPT_VER="1.0" - -GIT_MSG="Release: " -REL_NOTE="" -REL_PREFIX="release/" -PUSH_DEST="origin" - -# Show credits & help -usage() { - echo -e "$GREEN" \ - "\n █▄▄ █░█ █▀▄▀█ █▀█ ▄▄ █░█ █▀▀ █▀█ █▀ █ █▀█ █▄░█ " \ - "\n █▄█ █▄█ █░▀░█ █▀▀ ░░ ▀▄▀ ██▄ █▀▄ ▄█ █ █▄█ █░▀█ " \ - "\n\t\t\t\t\t$LIGHTGRAY v${SCRIPT_VER}" - - echo -e " ${S_NORM}${BOLD}Usage:${RESET}" \ - "\n $0 [-v ] [-m ] [-n] [-p] [-h]" 1>&2 - - echo -e "\n ${S_NORM}${BOLD}Options:${RESET}" - echo -e " $S_WARN-v$S_NORM \tSpecify a manual version number" - echo -e " $S_WARN-m$S_NORM \tCustom release message." - echo -e " $S_WARN-p$S_NORM \t\t\tPush commits to ORIGIN. " - echo -e " $S_WARN-n$S_NORM \t\t\tDon't perform a commit automatically. " \ - "\n\t\t\t* You may want to do that manually after checking everything, for example." - echo -e " $S_WARN-b$S_NORM \t\t\tDon't create automatic \`release-\` branch" - echo -e " $S_WARN-h$S_NORM \t\t\tShow this help message. " - echo -e "\n ${S_NORM}${BOLD}Original author: $S_LIGHT https://github.com/jv-t/bump-version $RESET" - echo -e "\n ${S_NORM}${BOLD}Changes by: $S_LIGHT https://github.com/d4rken $RESET\n" -} - -# If there are no commits in repo, quit, because you can't tag with zero commits. -check-commits-exist() { - git rev-parse HEAD &>/dev/null - if [ ! "$?" -eq 0 ]; then - echo -e "\n${I_STOP} ${S_ERROR}Your current branch doesn't have any commits yet. Can't tag without at least one commit." >&2 - echo - exit 1 - fi -} - -exit_abnormal() { - echo -e " ${S_LIGHT}––––––" - usage # Show help - exit 1 -} - -# Process script options -process-arguments() { - local OPTIONS OPTIND OPTARG - - # Get positional parameters - JSON_FILES=() - while getopts ":v:p:m:hbn" OPTIONS; do # Note: Adding the first : before the flags takes control of flags and prevents default error msgs. - case "$OPTIONS" in - h) - # Show help - exit_abnormal - ;; - v) - # User has supplied a version number - V_USR_SUPPLIED=$OPTARG - ;; - m) - REL_NOTE=$OPTARG - # Custom release note - echo -e "\n${S_LIGHT}Option set: ${S_NOTICE}Release note:" ${S_NORM}"'"$REL_NOTE"'" - ;; - p) - FLAG_PUSH=true - PUSH_DEST=${OPTARG} # Replace default with user input - echo -e "\n${S_LIGHT}Option set: ${S_NOTICE}Pushing to <${S_NORM}${PUSH_DEST}${S_LIGHT}>, as the last action in this script." - ;; - n) - FLAG_NOCOMMIT=true - echo -e "\n${S_LIGHT}Option set: ${S_NOTICE}Disable commit after tagging." - ;; - b) - FLAG_NOBRANCH=true - echo -e "\n${S_LIGHT}Option set: ${S_NOTICE}Disable committing to new branch." - ;; - \?) - echo -e "\n${I_ERROR}${S_ERROR} Invalid option: ${S_WARN}-$OPTARG" >&2 - echo - exit_abnormal - ;; - :) - echo -e "\n${I_ERROR}${S_ERROR} Option ${S_WARN}-$OPTARG ${S_ERROR}requires an argument." >&2 - echo - exit_abnormal - ;; - esac - done -} - -# Suggests version from VERSION file, or grabs from user supplied -v . -# If none is set, suggest default from options. -process-version() { - V_RAW="" - - V_FILE_REGEX='^([0-9]+\.[0-9]+\.[0-9]+-[a-zA-Z]+[0-9]+) ([0-9]+)$' - V_FILE_RAW="$(cat VERSION)" - if [ -f VERSION ] && [ -s VERSION ] && [[ $V_FILE_RAW =~ $V_FILE_REGEX ]]; then - V_PREV="${BASH_REMATCH[1]}" - V_SUGGEST=$V_PREV - echo -e "\n${S_NOTICE}Current version from <${S_NORM}VERSION${S_NOTICE}> file: ${S_NORM}$V_PREV" - else - echo -ne "\n${S_WARN}The [${S_NORM}VERSION${S_WARN}] " - if [ ! -f VERSION ]; then - echo "VERSION file was not found." - elif [ ! -s VERSION ]; then - echo "VERSION file is empty." - else - echo "could not be parsed." - fi - fi - - # If a version number is supplied by the user with [-v ], then use it - if [ -n "$V_USR_SUPPLIED" ]; then - echo -e "\n${S_NOTICE}You selected version using [-v]:" "${S_WARN}${V_USR_SUPPLIED}" - V_RAW="${V_USR_SUPPLIED}" - else - echo -ne "\n${S_QUESTION}Enter a new version number [${S_NORM}$V_SUGGEST${S_QUESTION}]: " - echo -ne "$S_WARN" - read -r V_RAW - fi - - if [ -z "$V_RAW" ]; then - V_RAW=$V_PREV - fi - - if [ -z "$V_RAW" ]; then - echo -e "\n${I_STOP} ${S_ERROR}Error: No version was supplied (no file, no CLI)\n" - exit_abnormal - fi - - SEMVER_REGEX='^([0-9]+)\.([0-9]+)\.([0-9]+)-([a-zA-Z]+)([0-9]+)$' - - echo -e "\n${S_NOTICE}Parsing ${V_RAW}" - - if [[ $V_RAW =~ $SEMVER_REGEX ]]; then - echo -e "\n${I_OK} ${S_NOTICE} Successfully parsed ${V_RAW} to ${BASH_REMATCH[0]}" - V_MAJOR="${BASH_REMATCH[1]}" - echo "V_MAJOR=$V_MAJOR" - V_MINOR="${BASH_REMATCH[2]}" - echo "V_MINOR=$V_MINOR" - V_PATCH="${BASH_REMATCH[3]}" - echo "V_PATCH=$V_PATCH" - V_BUILD_TYPE="${BASH_REMATCH[4]}" - echo "V_BUILD_TYPE=$V_BUILD_TYPE" - V_BUILD_COUNTER="${BASH_REMATCH[5]}" - echo "V_BUILD_COUNTER=$V_BUILD_COUNTER" - else - echo -e "\n${I_STOP} ${S_ERROR}Error: Failed to parse $V_RAW\n" - exit_abnormal - fi - - # If no version was provided, bump the previous version - if [ -z "$V_USR_SUPPLIED" ]; then - if [ "$V_BUILD_COUNTER" -eq "$V_BUILD_COUNTER" ] 2>/dev/null; then # discard stderr (2) output to black hole (suppress it) - V_BUILD_COUNTER=$((V_BUILD_COUNTER + 1)) # Increment - fi - fi - - V_NAME="$V_MAJOR.$V_MINOR.$V_PATCH-$V_BUILD_TYPE$V_BUILD_COUNTER" - V_CODE=$((V_MAJOR * 10000000 + V_MINOR * 100000 + V_PATCH * 1000 + V_BUILD_COUNTER * 10)) - echo -e "${S_NOTICE}Setting version to [${S_NORM}${V_NAME} (${V_CODE})${S_NOTICE}] ...." -} - -# Only tag if tag doesn't already exist -check-tag-exists() { - TAG_CHECK_EXISTS=$(git tag -l v"$V_NAME") - if [ -n "$TAG_CHECK_EXISTS" ]; then - echo -e "\n${I_STOP} ${S_ERROR}Error: A release with that tag version number already exists!\n" - exit 0 - fi -} - -# $1 : version -# $2 : release note -create-tag() { - if [ -z "$2" ]; then - # Default release note - git tag -a "v$1" -m "Tag version $1." - else - # Custom release note - git tag -a "v$1" -m "$2" - fi - echo -e "\n${I_OK} ${S_NOTICE}Added GIT tag" -} - -# Update version.properties which is used by Gradle to generate the `versionName` and `versionCode` -do-version-properties() { - PROPS_FILE_NAME="version.properties" - echo -e "\n${S_NOTICE}Parsing ${PROPS_FILE_NAME}:\n" - - V_MAJOR_REGEX='^([a-zA-Z\.]+major)=([0-9]+)$' - V_MINOR_REGEX='^([a-zA-Z\.]+minor)=([0-9]+)$' - V_PATCH_REGEX='^([a-zA-Z\.]+patch)=([0-9]+)$' - V_BUILD_REGEX='^([a-zA-Z\.]+build)=([0-9]+)$' - V_TYPE_REGEX='^([a-zA-Z\.]+type)=(rc|beta)$' - - PROPS_FILE_NEW="" - - LAST_LINE=$(wc -l <$PROPS_FILE_NAME) - CURRENT_LINE=0 - - while read -r line; do - CURRENT_LINE=$((CURRENT_LINE + 1)) - - if [[ $line =~ $V_MAJOR_REGEX ]]; then - updated="${BASH_REMATCH[1]}=${V_MAJOR}" - echo "Found major, replacing: $line -> $updated" - PROPS_FILE_NEW+=$updated - elif [[ $line =~ $V_MINOR_REGEX ]]; then - updated="${BASH_REMATCH[1]}=${V_MINOR}" - echo "Found minor, replacing: $line -> $updated" - PROPS_FILE_NEW+=$updated - elif [[ $line =~ $V_PATCH_REGEX ]]; then - updated="${BASH_REMATCH[1]}=${V_PATCH}" - echo "Found patch, replacing: $line -> $updated" - PROPS_FILE_NEW+=$updated - elif [[ $line =~ $V_BUILD_REGEX ]]; then - updated="${BASH_REMATCH[1]}=${V_BUILD_COUNTER}" - echo "Found build, replacing: $line -> $updated" - PROPS_FILE_NEW+=$updated - elif [[ $line =~ $V_TYPE_REGEX ]]; then - updated="${BASH_REMATCH[1]}=${V_BUILD_TYPE}" - echo "Found type, replacing: $line -> $updated" - PROPS_FILE_NEW+=$updated - else - PROPS_FILE_NEW+="$line" - fi - - if [[ $CURRENT_LINE -ne $LAST_LINE ]]; then - PROPS_FILE_NEW+="\n" - fi - - done <"$PROPS_FILE_NAME" - - echo -e "$PROPS_FILE_NEW" >"$PROPS_FILE_NAME" - git add "$PROPS_FILE_NAME" - - echo -e "\n${I_OK} ${S_NOTICE}Updated [${S_NORM}${PROPS_FILE_NAME}${S_NOTICE}] file" -} - -# Update a version file that can be parsed by third-parties, e.g. F-Droid -do-versionfile() { - [ -f VERSION ] && ACTION_MSG="Updated" || ACTION_MSG="Created" - - echo "${V_NAME} ${V_CODE}" >VERSION # Create file - echo -e "\n${I_OK} ${S_NOTICE}${ACTION_MSG} [${S_NORM}VERSION${S_NOTICE}] file" - - # Stage file for commit - git add VERSION -} - -# Does the release branch already exist? -check-branch-exist() { - [ "$FLAG_NOBRANCH" = true ] && return - - BRANCH_MSG=$(git rev-parse --verify "${REL_PREFIX}${V_NAME}" 2>&1) - if [ "$?" -eq 0 ]; then - echo -e "\n${I_STOP} ${S_ERROR}Error: Branch <${S_NORM}${REL_PREFIX}${V_NAME}${S_ERROR}> already exists!\n" - exit 1 - fi -} - -# Create release branch if desired -do-branch() { - [ "$FLAG_NOBRANCH" = true ] && return - - echo -e "\n${S_NOTICE}Creating new release branch..." - - BRANCH_MSG=$(git branch "${REL_PREFIX}${V_NAME}" 2>&1) - if [ ! "$?" -eq 0 ]; then - echo -e "\n${I_STOP} ${S_ERROR}Error\n$BRANCH_MSG\n" - exit 1 - else - BRANCH_MSG=$(git checkout "${REL_PREFIX}${V_NAME}" 2>&1) - echo -e "\n${I_OK} ${S_NOTICE}${BRANCH_MSG}" - fi -} - -# Stage & commit all files modified by this script -do-commit() { - [ "$FLAG_NOCOMMIT" = true ] && return - - echo -e "\n${S_NOTICE}Committing..." - COMMIT_MSG=$(git commit -m "${GIT_MSG}" 2>&1) - if [ ! "$?" -eq 0 ]; then - echo -e "\n${I_STOP} ${S_ERROR}Error\n$COMMIT_MSG\n" - exit 1 - else - echo -e "\n${I_OK} ${S_NOTICE}$COMMIT_MSG" - fi -} - -# Pushes files + tags to remote repo. Changes are staged by earlier functions -do-push() { - [ "$FLAG_NOCOMMIT" = true ] && return - - if [ "$FLAG_PUSH" = true ]; then - CONFIRM="Y" - else - echo -ne "\n${S_QUESTION}Push tags to <${S_NORM}${PUSH_DEST}${S_QUESTION}>? [${S_NORM}N/y${S_QUESTION}]: " - read CONFIRM - fi - - case "$CONFIRM" in - [yY][eE][sS] | [yY]) - echo -e "\n${S_NOTICE}Pushing files + tags to <${S_NORM}${PUSH_DEST}${S_NOTICE}>..." - - PUSH_MSG=$(git push "${PUSH_DEST}" v"$V_NAME" 2>&1) # Push new tag - PUSH_MSG+="\n" - PUSH_MSG+=$(git push 2>&1) # Push new tag - - if [ ! "$?" -eq 0 ]; then - echo -e "\n${I_STOP} ${S_WARN}Warning\n$PUSH_MSG" - # exit 1 - else - echo -e "\n${I_OK} ${S_NOTICE}$PUSH_MSG" - fi - ;; - esac -} - -#### Initiate Script ########################### - -check-commits-exist - -# Process and prepare -process-arguments "$@" -process-version - -GIT_MSG+="${V_NAME}" - -check-branch-exist -check-tag-exists - -echo -e "\n${S_LIGHT}––––––" - -# Update steps -do-version-properties -do-versionfile -do-branch -do-commit -create-tag "${V_NAME}" "${REL_NOTE}" -do-push - -echo -e "\n${S_LIGHT}––––––" -echo -e "\n${I_OK} ${S_NOTICE}"Bumped $([ -n "${V_PREV}" ] && echo "${V_PREV} –>" || echo "to ") "$V_NAME" -echo -e "\n${GREEN}Done ${I_END}\n" diff --git a/tools/release/bump.bats b/tools/release/bump.bats new file mode 100644 index 00000000..5c2cf166 --- /dev/null +++ b/tools/release/bump.bats @@ -0,0 +1,259 @@ +#!/usr/bin/env bats +# Unit tests for bump.sh. Run with: bats tools/release/bump.bats + +setup() { + BUMP_SH="${BATS_TEST_DIRNAME}/bump.sh" + TMP_REPO="$(mktemp -d)" + cat > "$TMP_REPO/version.properties" <<'EOF' +### Updated by release.sh ### +project.versioning.major=5 +project.versioning.minor=1 +project.versioning.patch=1 +project.versioning.build=0 +project.versioning.type=rc +############################# +EOF + echo "5.1.1-rc0 50101000" > "$TMP_REPO/VERSION" +} + +teardown() { + rm -rf "$TMP_REPO" +} + +bump() { + "$BUMP_SH" --repo-root="$TMP_REPO" "$@" +} + +# ----- check mode ---------------------------------------------------------- + +@test "check: passes on consistent state" { + run bump --mode=check + [ "$status" -eq 0 ] + [[ "$output" == *"current_name=5.1.1-rc0"* ]] + [[ "$output" == *"current_code=50101000"* ]] +} + +@test "check: fails on VERSION/version.properties drift (name)" { + echo "5.1.0-rc0 50100000" > "$TMP_REPO/VERSION" + run bump --mode=check + [ "$status" -ne 0 ] + [[ "$output" == *"drift"* ]] +} + +@test "check: fails on VERSION/version.properties drift (code)" { + echo "5.1.1-rc0 99999999" > "$TMP_REPO/VERSION" + run bump --mode=check + [ "$status" -ne 0 ] + [[ "$output" == *"drift"* ]] +} + +@test "check: fails on duplicate key" { + echo "project.versioning.major=6" >> "$TMP_REPO/version.properties" + run bump --mode=check + [ "$status" -ne 0 ] + [[ "$output" == *"major"* ]] + [[ "$output" == *"found 2"* ]] +} + +@test "check: fails on missing key" { + sed -i '/project\.versioning\.type=/d' "$TMP_REPO/version.properties" + run bump --mode=check + [ "$status" -ne 0 ] + [[ "$output" == *"type"* ]] + [[ "$output" == *"found 0"* ]] +} + +@test "check: fails on bad type" { + sed -i 's/^project\.versioning\.type=.*/project.versioning.type=foo/' "$TMP_REPO/version.properties" + run bump --mode=check + [ "$status" -ne 0 ] +} + +@test "check: fails on malformed VERSION" { + echo "garbage" > "$TMP_REPO/VERSION" + run bump --mode=check + [ "$status" -ne 0 ] + [[ "$output" == *"VERSION file does not match"* ]] +} + +# ----- plan: bump kinds ---------------------------------------------------- + +@test "plan: build bump increments build" { + run bump --mode=plan --bump-kind=build --version-type=keep-current + [ "$status" -eq 0 ] + [[ "$output" == *"new_name=5.1.1-rc1"* ]] + [[ "$output" == *"new_code=50101010"* ]] +} + +@test "plan: patch bump zeros build" { + run bump --mode=plan --bump-kind=patch --version-type=keep-current + [ "$status" -eq 0 ] + [[ "$output" == *"new_name=5.1.2-rc0"* ]] + [[ "$output" == *"new_code=50102000"* ]] +} + +@test "plan: minor bump zeros patch and build" { + run bump --mode=plan --bump-kind=minor --version-type=keep-current + [ "$status" -eq 0 ] + [[ "$output" == *"new_name=5.2.0-rc0"* ]] + [[ "$output" == *"new_code=50200000"* ]] +} + +@test "plan: major bump zeros minor, patch, build" { + run bump --mode=plan --bump-kind=major --version-type=keep-current + [ "$status" -eq 0 ] + [[ "$output" == *"new_name=6.0.0-rc0"* ]] + [[ "$output" == *"new_code=60000000"* ]] +} + +@test "plan: version-type beta switches type" { + run bump --mode=plan --bump-kind=build --version-type=beta + [ "$status" -eq 0 ] + [[ "$output" == *"new_name=5.1.1-beta1"* ]] +} + +@test "plan: keep-current preserves type" { + # change current type to beta in fixture + sed -i 's/^project\.versioning\.type=.*/project.versioning.type=beta/' "$TMP_REPO/version.properties" + echo "5.1.1-beta0 50101000" > "$TMP_REPO/VERSION" + run bump --mode=plan --bump-kind=build --version-type=keep-current + [ "$status" -eq 0 ] + [[ "$output" == *"new_name=5.1.1-beta1"* ]] +} + +# ----- plan: override ------------------------------------------------------ + +@test "plan: version-override accepts valid version" { + run bump --mode=plan --version-override=5.2.0-rc0 + [ "$status" -eq 0 ] + [[ "$output" == *"new_name=5.2.0-rc0"* ]] + [[ "$output" == *"new_code=50200000"* ]] +} + +@test "plan: version-override rejects bad regex (build=100)" { + run bump --mode=plan --version-override=5.1.2-rc100 + [ "$status" -ne 0 ] + [[ "$output" == *"does not match"* ]] +} + +@test "plan: version-override rejects bad type" { + run bump --mode=plan --version-override=5.1.2-alpha0 + [ "$status" -ne 0 ] +} + +@test "plan: version-override rejects leading zero" { + run bump --mode=plan --version-override=5.01.0-rc0 + [ "$status" -ne 0 ] + [[ "$output" == *"leading zero"* ]] +} + +@test "plan: version-override rejects no-op identity" { + run bump --mode=plan --version-override=5.1.1-rc0 + [ "$status" -ne 0 ] + [[ "$output" == *"no-op"* ]] +} + +@test "plan: version-override rejects monotonicity break" { + run bump --mode=plan --version-override=5.1.0-rc0 + [ "$status" -ne 0 ] + [[ "$output" == *"monotonicity"* ]] +} + +# ----- plan: bounds -------------------------------------------------------- + +@test "plan: rejects build overflow when bumping past 99" { + sed -i 's/^project\.versioning\.build=.*/project.versioning.build=99/' "$TMP_REPO/version.properties" + echo "5.1.1-rc99 50101990" > "$TMP_REPO/VERSION" + run bump --mode=plan --bump-kind=build --version-type=keep-current + [ "$status" -ne 0 ] + [[ "$output" == *"out of range"* ]] +} + +@test "plan: rejects patch overflow when bumping past 99" { + sed -i 's/^project\.versioning\.patch=.*/project.versioning.patch=99/' "$TMP_REPO/version.properties" + echo "5.1.99-rc0 50199000" > "$TMP_REPO/VERSION" + run bump --mode=plan --bump-kind=patch --version-type=keep-current + [ "$status" -ne 0 ] + [[ "$output" == *"out of range"* ]] +} + +# ----- plan: expected-current ---------------------------------------------- + +@test "plan: expected-current matches passes" { + run bump --mode=plan --bump-kind=build --version-type=keep-current --expected-current=5.1.1-rc0 + [ "$status" -eq 0 ] +} + +@test "plan: expected-current mismatch fails" { + run bump --mode=plan --bump-kind=build --version-type=keep-current --expected-current=5.0.0-rc0 + [ "$status" -ne 0 ] + [[ "$output" == *"expected-current"* ]] +} + +# ----- write mode ---------------------------------------------------------- + +@test "write: rewrites both files correctly" { + run bump --mode=write --bump-kind=build --version-type=keep-current + [ "$status" -eq 0 ] + grep -q '^project\.versioning\.build=1$' "$TMP_REPO/version.properties" + [ "$(cat "$TMP_REPO/VERSION")" = "5.1.1-rc1 50101010" ] +} + +@test "write: preserves comment block" { + run bump --mode=write --bump-kind=patch --version-type=keep-current + [ "$status" -eq 0 ] + grep -q '^### Updated by tools/release/bump.sh ###$' "$TMP_REPO/version.properties" + grep -q '^#############################$' "$TMP_REPO/version.properties" +} + +@test "write: preserves key order" { + run bump --mode=write --bump-kind=patch --version-type=keep-current + [ "$status" -eq 0 ] + grep -nE '^project\.versioning\.' "$TMP_REPO/version.properties" > "$TMP_REPO/order.txt" + # Five keys, in order: major, minor, patch, build, type. + run cat "$TMP_REPO/order.txt" + [[ "${lines[0]}" == *"major="* ]] + [[ "${lines[1]}" == *"minor="* ]] + [[ "${lines[2]}" == *"patch="* ]] + [[ "${lines[3]}" == *"build="* ]] + [[ "${lines[4]}" == *"type="* ]] +} + +@test "write: type switch persists" { + run bump --mode=write --bump-kind=build --version-type=beta + [ "$status" -eq 0 ] + grep -q '^project\.versioning\.type=beta$' "$TMP_REPO/version.properties" + [ "$(cat "$TMP_REPO/VERSION")" = "5.1.1-beta1 50101010" ] +} + +@test "write: idempotent re-check passes" { + run bump --mode=write --bump-kind=build --version-type=keep-current + [ "$status" -eq 0 ] + run bump --mode=check + [ "$status" -eq 0 ] + [[ "$output" == *"current_name=5.1.1-rc1"* ]] +} + +@test "write: header refresh idempotent if already updated" { + sed -i 's|^### Updated by release\.sh ###$|### Updated by tools/release/bump.sh ###|' "$TMP_REPO/version.properties" + run bump --mode=write --bump-kind=build --version-type=keep-current + [ "$status" -eq 0 ] + [ "$(grep -c '^### Updated by tools/release/bump.sh ###$' "$TMP_REPO/version.properties")" -eq 1 ] +} + +# ----- mode parsing -------------------------------------------------------- + +@test "rejects missing --mode" { + run "$BUMP_SH" --repo-root="$TMP_REPO" + [ "$status" -ne 0 ] +} + +@test "rejects invalid --mode" { + run "$BUMP_SH" --repo-root="$TMP_REPO" --mode=foo + [ "$status" -ne 0 ] +} + +@test "rejects unknown flag" { + run "$BUMP_SH" --repo-root="$TMP_REPO" --mode=check --frobnicate=yes + [ "$status" -ne 0 ] +} diff --git a/tools/release/bump.sh b/tools/release/bump.sh new file mode 100755 index 00000000..8bc42ca1 --- /dev/null +++ b/tools/release/bump.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +# Source of truth for version bumping. Used by release-prepare.yml and release-tag.yml. +# Mirrors the versionCode formula in buildSrc/src/main/java/ProjectConfig.kt. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bump.sh --mode= [options] + +Modes: + check Validate version.properties + VERSION are consistent. No mutation. + plan check + compute the new version per inputs. Print plan to stdout. + write plan + rewrite version.properties and VERSION, verify post-condition. + +Options (for plan/write): + --bump-kind=build|patch|minor|major (default: build) + --version-type=keep-current|rc|beta (default: keep-current) + --version-override= (overrides bump-kind/version-type) + --expected-current= (fail if current version differs) + --repo-root= (default: current working directory) + +Output (plan/write modes): + Human-readable report on stderr; KEY=value pairs on stdout for parsing: + current_name=... + current_code=... + new_name=... + new_code=... +EOF +} + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +log() { + echo "$*" >&2 +} + +# ----- argument parsing ---------------------------------------------------- + +mode="" +bump_kind="build" +version_type="keep-current" +version_override="" +expected_current="" +repo_root="" + +for arg in "$@"; do + case "$arg" in + --mode=*) mode="${arg#*=}" ;; + --bump-kind=*) bump_kind="${arg#*=}" ;; + --version-type=*) version_type="${arg#*=}" ;; + --version-override=*) version_override="${arg#*=}" ;; + --expected-current=*) expected_current="${arg#*=}" ;; + --repo-root=*) repo_root="${arg#*=}" ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $arg" ;; + esac +done + +case "$mode" in + check|plan|write) ;; + "") usage >&2; exit 2 ;; + *) die "invalid --mode: $mode (expected check|plan|write)" ;; +esac + +case "$bump_kind" in + build|patch|minor|major) ;; + *) die "invalid --bump-kind: $bump_kind" ;; +esac + +case "$version_type" in + keep-current|rc|beta) ;; + *) die "invalid --version-type: $version_type" ;; +esac + +if [[ -z "$repo_root" ]]; then + repo_root="$(pwd)" +fi + +if [[ ! -d "$repo_root" ]]; then + die "repo root does not exist: $repo_root" +fi + +props_file="$repo_root/version.properties" +version_file="$repo_root/VERSION" + +# ----- helpers ------------------------------------------------------------- + +# Reject leading zeros on numeric components (allow plain "0"). +no_leading_zero() { + local n="$1" label="$2" + [[ "$n" =~ ^0$ || "$n" =~ ^[1-9][0-9]*$ ]] || die "$label has leading zero or invalid digits: '$n'" +} + +bound_0_99() { + local n="$1" label="$2" + [[ "$n" -ge 0 && "$n" -le 99 ]] || die "$label out of range 0..99: $n" +} + +parse_name() { + # Sets globals: pn_major, pn_minor, pn_patch, pn_type, pn_build + local name="$1" label="$2" + if [[ ! "$name" =~ ^([0-9]{1,2})\.([0-9]{1,2})\.([0-9]{1,2})-(rc|beta)([0-9]{1,2})$ ]]; then + die "$label does not match : '$name'" + fi + pn_major="${BASH_REMATCH[1]}" + pn_minor="${BASH_REMATCH[2]}" + pn_patch="${BASH_REMATCH[3]}" + pn_type="${BASH_REMATCH[4]}" + pn_build="${BASH_REMATCH[5]}" + no_leading_zero "$pn_major" "$label major" + no_leading_zero "$pn_minor" "$label minor" + no_leading_zero "$pn_patch" "$label patch" + no_leading_zero "$pn_build" "$label build" +} + +# Compute versionCode the same way ProjectConfig.kt does. +compute_code() { + local major="$1" minor="$2" patch="$3" build="$4" + echo $(( major * 10000000 + minor * 100000 + patch * 1000 + build * 10 )) +} + +format_name() { + local major="$1" minor="$2" patch="$3" type="$4" build="$5" + echo "${major}.${minor}.${patch}-${type}${build}" +} + +# Count exact matches of an anchored regex in a file. +count_matches() { + local pattern="$1" file="$2" + grep -cE "$pattern" "$file" || true +} + +# ----- read & validate current state --------------------------------------- + +[[ -f "$props_file" ]] || die "missing version.properties at $props_file" +[[ -f "$version_file" ]] || die "missing VERSION file at $version_file" + +# Each of the five keys must appear exactly once on its own line. +expect_one() { + local key_pattern="$1" label="$2" file="$3" + local n + n=$(count_matches "$key_pattern" "$file") + [[ "$n" == "1" ]] || die "$label: expected exactly 1 line in $file matching '$key_pattern', found $n" +} + +expect_one '^project\.versioning\.major=[0-9]+$' "major" "$props_file" +expect_one '^project\.versioning\.minor=[0-9]+$' "minor" "$props_file" +expect_one '^project\.versioning\.patch=[0-9]+$' "patch" "$props_file" +expect_one '^project\.versioning\.build=[0-9]+$' "build" "$props_file" +expect_one '^project\.versioning\.type=(rc|beta)$' "type" "$props_file" + +cur_major=$(grep -E '^project\.versioning\.major=' "$props_file" | cut -d= -f2) +cur_minor=$(grep -E '^project\.versioning\.minor=' "$props_file" | cut -d= -f2) +cur_patch=$(grep -E '^project\.versioning\.patch=' "$props_file" | cut -d= -f2) +cur_build=$(grep -E '^project\.versioning\.build=' "$props_file" | cut -d= -f2) +cur_type=$(grep -E '^project\.versioning\.type=' "$props_file" | cut -d= -f2) + +no_leading_zero "$cur_major" "current major" +no_leading_zero "$cur_minor" "current minor" +no_leading_zero "$cur_patch" "current patch" +no_leading_zero "$cur_build" "current build" +bound_0_99 "$cur_major" "current major" +bound_0_99 "$cur_minor" "current minor" +bound_0_99 "$cur_patch" "current patch" +bound_0_99 "$cur_build" "current build" + +cur_name=$(format_name "$cur_major" "$cur_minor" "$cur_patch" "$cur_type" "$cur_build") +cur_code=$(compute_code "$cur_major" "$cur_minor" "$cur_patch" "$cur_build") + +# VERSION file: exactly one line, " ". +version_line_count=$(wc -l < "$version_file" | tr -d ' ') +# Allow trailing newline (line count 1) — reject anything else. +[[ "$version_line_count" -ge 1 && "$version_line_count" -le 1 ]] \ + || die "VERSION file must have exactly one line, found $version_line_count" + +version_content=$(head -n1 "$version_file") +if [[ ! "$version_content" =~ ^([^[:space:]]+)\ ([0-9]+)$ ]]; then + die "VERSION file does not match ' ': '$version_content'" +fi +file_name="${BASH_REMATCH[1]}" +file_code="${BASH_REMATCH[2]}" + +# Drift check: VERSION must agree with version.properties. +[[ "$file_name" == "$cur_name" ]] \ + || die "drift: VERSION name '$file_name' != version.properties name '$cur_name'" +[[ "$file_code" == "$cur_code" ]] \ + || die "drift: VERSION code '$file_code' != computed code '$cur_code'" + +if [[ -n "$expected_current" ]]; then + [[ "$expected_current" == "$cur_name" ]] \ + || die "expected-current '$expected_current' != actual current '$cur_name'" +fi + +log "current: $cur_name (code $cur_code)" + +if [[ "$mode" == "check" ]]; then + echo "current_name=$cur_name" + echo "current_code=$cur_code" + exit 0 +fi + +# ----- compute new version ------------------------------------------------- + +if [[ -n "$version_override" ]]; then + parse_name "$version_override" "version-override" + new_major="$pn_major" + new_minor="$pn_minor" + new_patch="$pn_patch" + new_type="$pn_type" + new_build="$pn_build" +else + new_major="$cur_major" + new_minor="$cur_minor" + new_patch="$cur_patch" + new_build="$cur_build" + + case "$bump_kind" in + build) new_build=$((cur_build + 1)) ;; + patch) new_patch=$((cur_patch + 1)); new_build=0 ;; + minor) new_minor=$((cur_minor + 1)); new_patch=0; new_build=0 ;; + major) new_major=$((cur_major + 1)); new_minor=0; new_patch=0; new_build=0 ;; + esac + + case "$version_type" in + keep-current) new_type="$cur_type" ;; + rc|beta) new_type="$version_type" ;; + esac +fi + +bound_0_99 "$new_major" "new major" +bound_0_99 "$new_minor" "new minor" +bound_0_99 "$new_patch" "new patch" +bound_0_99 "$new_build" "new build" + +case "$new_type" in + rc|beta) ;; + *) die "computed type must be rc or beta, got '$new_type'" ;; +esac + +new_name=$(format_name "$new_major" "$new_minor" "$new_patch" "$new_type" "$new_build") +new_code=$(compute_code "$new_major" "$new_minor" "$new_patch" "$new_build") + +# Sanity: int range. Android versionCode is Int (max 2_147_483_647). +# At major=99 the code is ~990M, well within range — keep the assertion anyway. +[[ "$new_code" -le 2147483647 ]] || die "new versionCode exceeds Int.MAX_VALUE: $new_code" + +[[ "$new_name" != "$cur_name" ]] || die "no-op: new name equals current ($new_name)" +[[ "$new_code" -gt "$cur_code" ]] \ + || die "monotonicity: new code ($new_code) is not greater than current ($cur_code)" + +log "new: $new_name (code $new_code)" + +# ----- output -------------------------------------------------------------- + +echo "current_name=$cur_name" +echo "current_code=$cur_code" +echo "new_name=$new_name" +echo "new_code=$new_code" + +if [[ "$mode" == "plan" ]]; then + log "" + log "--- diff (plan) ---" + log " version.properties:" + log " major: $cur_major -> $new_major" + log " minor: $cur_minor -> $new_minor" + log " patch: $cur_patch -> $new_patch" + log " build: $cur_build -> $new_build" + log " type: $cur_type -> $new_type" + log " VERSION:" + log " -$cur_name $cur_code" + log " +$new_name $new_code" + exit 0 +fi + +# ----- write mode ---------------------------------------------------------- + +# Use sed -E with anchored patterns. Replace each property line in place. +# sed exits 0 even if a pattern doesn't match — we count matches before and +# verify by re-parsing after. + +sed_inplace() { + if [[ "$(uname)" == "Darwin" ]]; then + sed -i '' -E "$@" + else + sed -i -E "$@" + fi +} + +sed_inplace "s#^(project\.versioning\.major=)[0-9]+\$#\1${new_major}#" "$props_file" +sed_inplace "s#^(project\.versioning\.minor=)[0-9]+\$#\1${new_minor}#" "$props_file" +sed_inplace "s#^(project\.versioning\.patch=)[0-9]+\$#\1${new_patch}#" "$props_file" +sed_inplace "s#^(project\.versioning\.build=)[0-9]+\$#\1${new_build}#" "$props_file" +sed_inplace "s#^(project\.versioning\.type=)(rc|beta)\$#\1${new_type}#" "$props_file" + +# Header comment refresh (idempotent — only updates if it still says the old text). +sed_inplace "s@^### Updated by release\.sh ###\$@### Updated by tools/release/bump.sh ###@" "$props_file" + +# Rewrite VERSION (single-line file). +printf '%s %s\n' "$new_name" "$new_code" > "$version_file" + +# ----- post-condition ------------------------------------------------------ + +# Re-read and verify the rewrite landed exactly as planned. +post_major=$(grep -E '^project\.versioning\.major=' "$props_file" | cut -d= -f2) +post_minor=$(grep -E '^project\.versioning\.minor=' "$props_file" | cut -d= -f2) +post_patch=$(grep -E '^project\.versioning\.patch=' "$props_file" | cut -d= -f2) +post_build=$(grep -E '^project\.versioning\.build=' "$props_file" | cut -d= -f2) +post_type=$(grep -E '^project\.versioning\.type=' "$props_file" | cut -d= -f2) + +[[ "$post_major" == "$new_major" ]] || die "post-condition: major did not write ($post_major != $new_major)" +[[ "$post_minor" == "$new_minor" ]] || die "post-condition: minor did not write ($post_minor != $new_minor)" +[[ "$post_patch" == "$new_patch" ]] || die "post-condition: patch did not write ($post_patch != $new_patch)" +[[ "$post_build" == "$new_build" ]] || die "post-condition: build did not write ($post_build != $new_build)" +[[ "$post_type" == "$new_type" ]] || die "post-condition: type did not write ($post_type != $new_type)" + +# Each key still appears exactly once. +expect_one '^project\.versioning\.major=[0-9]+$' "post major" "$props_file" +expect_one '^project\.versioning\.minor=[0-9]+$' "post minor" "$props_file" +expect_one '^project\.versioning\.patch=[0-9]+$' "post patch" "$props_file" +expect_one '^project\.versioning\.build=[0-9]+$' "post build" "$props_file" +expect_one '^project\.versioning\.type=(rc|beta)$' "post type" "$props_file" + +# VERSION re-parse. +post_version_content=$(head -n1 "$version_file") +[[ "$post_version_content" == "$new_name $new_code" ]] \ + || die "post-condition: VERSION did not write ('$post_version_content' != '$new_name $new_code')" + +log "wrote: $new_name (code $new_code)" diff --git a/version.properties b/version.properties index 108e020b..4ec4128b 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ -### Updated by release.sh ### +### Updated by tools/release/bump.sh ### project.versioning.major=5 project.versioning.minor=1 project.versioning.patch=1