From be9918014929c9d75e29d28da6d7c99e9a0e76ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 17 Feb 2026 11:33:44 +0000 Subject: [PATCH 1/4] feat: Wire build_number/version_number overrides through GitHub Actions Add optional build_number and version_number inputs to ios-fastlane-beta and ios-fastlane-release actions, and to the on-demand build workflow. The release action now parses version tags in x.y.z-k format to automatically extract build numbers from tags, while explicit build_number input takes precedence. Co-Authored-By: Claude Opus 4.6 --- .github/actions/ios-fastlane-beta/action.yml | 8 +++ .github/actions/ios-fastlane-beta/beta.sh | 12 +++- .../ios-fastlane-beta/test/test_beta.bats | 29 ++++++++++ .../ios-fastlane-beta/test/test_helper.bash | 27 +++++++++ .../actions/ios-fastlane-release/action.yml | 14 ++++- .../ios-fastlane-release/parse-version-tag.sh | 14 +++++ .../actions/ios-fastlane-release/release.sh | 12 +++- .../test/test_helper.bash | 8 +++ .../test/test_parse-version-tag.bats | 53 +++++++++++++++++ .../test/test_release.bats | 57 +++++++++++++++++++ .../ios-selfhosted-on-demand-build.yml | 10 ++++ 11 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 .github/actions/ios-fastlane-beta/test/test_beta.bats create mode 100644 .github/actions/ios-fastlane-beta/test/test_helper.bash create mode 100755 .github/actions/ios-fastlane-release/parse-version-tag.sh create mode 100644 .github/actions/ios-fastlane-release/test/test_helper.bash create mode 100644 .github/actions/ios-fastlane-release/test/test_parse-version-tag.bats create mode 100644 .github/actions/ios-fastlane-release/test/test_release.bats diff --git a/.github/actions/ios-fastlane-beta/action.yml b/.github/actions/ios-fastlane-beta/action.yml index f5086cd..4ae3604 100644 --- a/.github/actions/ios-fastlane-beta/action.yml +++ b/.github/actions/ios-fastlane-beta/action.yml @@ -16,6 +16,12 @@ inputs: app_store_connect_api_key_issuer_id: description: 'App Store Connect API Key Issuer ID' required: true + build_number: + description: 'Custom build number. Skips auto-increment from TestFlight.' + required: false + version_number: + description: 'Custom version number. Overrides default.' + required: false custom_values: description: 'Custom values' required: false @@ -37,6 +43,8 @@ runs: APP_STORE_CONNECT_API_KEY_KEY: ${{ inputs.app_store_connect_api_key_key }} APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ inputs.app_store_connect_api_key_key_id }} APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ inputs.app_store_connect_api_key_issuer_id }} + BUILD_NUMBER: ${{ inputs.build_number }} + VERSION_NUMBER: ${{ inputs.version_number }} CUSTOM_VALUES: ${{ inputs.custom_values }} IOS_ROOT_PATH: ${{ inputs.ios_root_path }} CUSTOM_BUILD_PATH: ${{ inputs.custom_build_path }} \ No newline at end of file diff --git a/.github/actions/ios-fastlane-beta/beta.sh b/.github/actions/ios-fastlane-beta/beta.sh index 86b0e11..9464cdb 100755 --- a/.github/actions/ios-fastlane-beta/beta.sh +++ b/.github/actions/ios-fastlane-beta/beta.sh @@ -16,5 +16,15 @@ if [ -n "$CUSTOM_BUILD_PATH" ]; then cd $GITHUB_WORKSPACE/$CUSTOM_BUILD_PATH fi +# Build fastlane arguments from optional overrides +FASTLANE_ARGS="" +if [ -n "$BUILD_NUMBER" ]; then + FASTLANE_ARGS="$FASTLANE_ARGS build_number:$BUILD_NUMBER" +fi +if [ -n "$VERSION_NUMBER" ]; then + FASTLANE_ARGS="$FASTLANE_ARGS version_number:$VERSION_NUMBER" +fi + # Environment variables are already set by action.yml -bundle exec fastlane beta +# shellcheck disable=SC2086 +bundle exec fastlane beta $FASTLANE_ARGS diff --git a/.github/actions/ios-fastlane-beta/test/test_beta.bats b/.github/actions/ios-fastlane-beta/test/test_beta.bats new file mode 100644 index 0000000..baf82e9 --- /dev/null +++ b/.github/actions/ios-fastlane-beta/test/test_beta.bats @@ -0,0 +1,29 @@ +#!/usr/bin/env bats + +load test_helper + +SCRIPT="$BATS_TEST_DIRNAME/../beta.sh" + +@test "no overrides — runs fastlane beta without args" { + BUILD_NUMBER="" VERSION_NUMBER="" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^exec fastlane beta$" "$BUNDLE_LOG" +} + +@test "BUILD_NUMBER set — passes build_number arg" { + BUILD_NUMBER="42" VERSION_NUMBER="" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^exec fastlane beta build_number:42$" "$BUNDLE_LOG" +} + +@test "VERSION_NUMBER set — passes version_number arg" { + BUILD_NUMBER="" VERSION_NUMBER="1.2.0" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^exec fastlane beta version_number:1.2.0$" "$BUNDLE_LOG" +} + +@test "both set — passes both args" { + BUILD_NUMBER="42" VERSION_NUMBER="1.2.0" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^exec fastlane beta build_number:42 version_number:1.2.0$" "$BUNDLE_LOG" +} diff --git a/.github/actions/ios-fastlane-beta/test/test_helper.bash b/.github/actions/ios-fastlane-beta/test/test_helper.bash new file mode 100644 index 0000000..2748649 --- /dev/null +++ b/.github/actions/ios-fastlane-beta/test/test_helper.bash @@ -0,0 +1,27 @@ +setup() { + MOCK_DIR="$(mktemp -d)" + export PATH="$MOCK_DIR:$PATH" + + # Mock gem command (no-op) + cat > "$MOCK_DIR/gem" <<'MOCK' +#!/bin/bash +exit 0 +MOCK + chmod +x "$MOCK_DIR/gem" + + # Mock bundle command — capture the full invocation + BUNDLE_LOG="$(mktemp)" + export BUNDLE_LOG + cat > "$MOCK_DIR/bundle" <> "$BUNDLE_LOG" +fi +exit 0 +MOCK + chmod +x "$MOCK_DIR/bundle" +} + +teardown() { + rm -rf "$MOCK_DIR" "$BUNDLE_LOG" +} diff --git a/.github/actions/ios-fastlane-release/action.yml b/.github/actions/ios-fastlane-release/action.yml index dd0a3d9..ba463b3 100644 --- a/.github/actions/ios-fastlane-release/action.yml +++ b/.github/actions/ios-fastlane-release/action.yml @@ -5,7 +5,10 @@ inputs: description: 'Match password' required: true version_number: - description: 'Version number' + description: 'Version number (supports x.y.z or x.y.z-k format where k is build number)' + required: false + build_number: + description: 'Custom build number. Skips auto-increment from TestFlight.' required: false app_store_connect_api_key_key: description: 'App Store Connect API Key' @@ -28,12 +31,19 @@ inputs: runs: using: 'composite' steps: + - name: Parse version tag + id: parse_version + shell: bash + run: ${{ github.action_path }}/parse-version-tag.sh + env: + VERSION_TAG: ${{ inputs.version_number }} - name: Run release script shell: bash run: ${{ github.action_path }}/release.sh env: MATCH_PASSWORD: ${{ inputs.match_password }} - VERSION_NUMBER: ${{ inputs.version_number }} + VERSION_NUMBER: ${{ steps.parse_version.outputs.version_number }} + BUILD_NUMBER: ${{ inputs.build_number || steps.parse_version.outputs.build_number }} APP_STORE_CONNECT_API_KEY_KEY: ${{ inputs.app_store_connect_api_key_key }} APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ inputs.app_store_connect_api_key_key_id }} APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ inputs.app_store_connect_api_key_issuer_id }} diff --git a/.github/actions/ios-fastlane-release/parse-version-tag.sh b/.github/actions/ios-fastlane-release/parse-version-tag.sh new file mode 100755 index 0000000..f243d47 --- /dev/null +++ b/.github/actions/ios-fastlane-release/parse-version-tag.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +TAG="$VERSION_TAG" + +if [[ "$TAG" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-([0-9]+))?$ ]]; then + echo "version_number=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + if [[ -n "${BASH_REMATCH[3]}" ]]; then + echo "build_number=${BASH_REMATCH[3]}" >> "$GITHUB_OUTPUT" + fi +else + echo "::error::Tag '$TAG' does not match expected format 'x.y.z' or 'x.y.z-k' (where k is an integer build number)" + exit 1 +fi diff --git a/.github/actions/ios-fastlane-release/release.sh b/.github/actions/ios-fastlane-release/release.sh index 2d9bf0f..c1673f3 100755 --- a/.github/actions/ios-fastlane-release/release.sh +++ b/.github/actions/ios-fastlane-release/release.sh @@ -16,5 +16,15 @@ if [ -n "$CUSTOM_BUILD_PATH" ]; then cd $GITHUB_WORKSPACE/$CUSTOM_BUILD_PATH fi +# Build fastlane arguments from optional overrides +FASTLANE_ARGS="" +if [ -n "$BUILD_NUMBER" ]; then + FASTLANE_ARGS="$FASTLANE_ARGS build_number:$BUILD_NUMBER" +fi +if [ -n "$VERSION_NUMBER" ]; then + FASTLANE_ARGS="$FASTLANE_ARGS version_number:$VERSION_NUMBER" +fi + # Environment variables are already set by action.yml -bundle exec fastlane release +# shellcheck disable=SC2086 +bundle exec fastlane release $FASTLANE_ARGS diff --git a/.github/actions/ios-fastlane-release/test/test_helper.bash b/.github/actions/ios-fastlane-release/test/test_helper.bash new file mode 100644 index 0000000..9e4a1fb --- /dev/null +++ b/.github/actions/ios-fastlane-release/test/test_helper.bash @@ -0,0 +1,8 @@ +setup() { + GITHUB_OUTPUT="$(mktemp)" + export GITHUB_OUTPUT +} + +teardown() { + rm -f "$GITHUB_OUTPUT" +} diff --git a/.github/actions/ios-fastlane-release/test/test_parse-version-tag.bats b/.github/actions/ios-fastlane-release/test/test_parse-version-tag.bats new file mode 100644 index 0000000..e688135 --- /dev/null +++ b/.github/actions/ios-fastlane-release/test/test_parse-version-tag.bats @@ -0,0 +1,53 @@ +#!/usr/bin/env bats + +load test_helper + +SCRIPT="$BATS_TEST_DIRNAME/../parse-version-tag.sh" + +@test "parses simple version tag 1.0.0" { + VERSION_TAG="1.0.0" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^version_number=1.0.0$" "$GITHUB_OUTPUT" + ! grep -q "^build_number=" "$GITHUB_OUTPUT" +} + +@test "parses version tag with build number 1.2.3-42" { + VERSION_TAG="1.2.3-42" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^version_number=1.2.3$" "$GITHUB_OUTPUT" + grep -q "^build_number=42$" "$GITHUB_OUTPUT" +} + +@test "parses large version numbers 10.20.30-999" { + VERSION_TAG="10.20.30-999" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^version_number=10.20.30$" "$GITHUB_OUTPUT" + grep -q "^build_number=999$" "$GITHUB_OUTPUT" +} + +@test "parses build number zero 1.0.0-0" { + VERSION_TAG="1.0.0-0" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^version_number=1.0.0$" "$GITHUB_OUTPUT" + grep -q "^build_number=0$" "$GITHUB_OUTPUT" +} + +@test "rejects semver pre-release tag 1.0.0-beta.1" { + VERSION_TAG="1.0.0-beta.1" run bash "$SCRIPT" + [ "$status" -eq 1 ] +} + +@test "rejects v-prefixed tag v1.0.0" { + VERSION_TAG="v1.0.0" run bash "$SCRIPT" + [ "$status" -eq 1 ] +} + +@test "rejects incomplete version 1.0" { + VERSION_TAG="1.0" run bash "$SCRIPT" + [ "$status" -eq 1 ] +} + +@test "rejects empty tag" { + VERSION_TAG="" run bash "$SCRIPT" + [ "$status" -eq 1 ] +} diff --git a/.github/actions/ios-fastlane-release/test/test_release.bats b/.github/actions/ios-fastlane-release/test/test_release.bats new file mode 100644 index 0000000..3b3e75b --- /dev/null +++ b/.github/actions/ios-fastlane-release/test/test_release.bats @@ -0,0 +1,57 @@ +#!/usr/bin/env bats + +load test_helper + +SCRIPT="$BATS_TEST_DIRNAME/../release.sh" + +setup() { + MOCK_DIR="$(mktemp -d)" + export PATH="$MOCK_DIR:$PATH" + + # Mock gem command (no-op) + cat > "$MOCK_DIR/gem" <<'MOCK' +#!/bin/bash +exit 0 +MOCK + chmod +x "$MOCK_DIR/gem" + + # Mock bundle command — capture the full invocation + BUNDLE_LOG="$(mktemp)" + export BUNDLE_LOG + cat > "$MOCK_DIR/bundle" <> "$BUNDLE_LOG" +fi +exit 0 +MOCK + chmod +x "$MOCK_DIR/bundle" +} + +teardown() { + rm -rf "$MOCK_DIR" "$BUNDLE_LOG" +} + +@test "no overrides — runs fastlane release without args" { + BUILD_NUMBER="" VERSION_NUMBER="" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^exec fastlane release$" "$BUNDLE_LOG" +} + +@test "BUILD_NUMBER set — passes build_number arg" { + BUILD_NUMBER="42" VERSION_NUMBER="" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^exec fastlane release build_number:42$" "$BUNDLE_LOG" +} + +@test "VERSION_NUMBER set — passes version_number arg" { + BUILD_NUMBER="" VERSION_NUMBER="1.2.0" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^exec fastlane release version_number:1.2.0$" "$BUNDLE_LOG" +} + +@test "both set — passes both args" { + BUILD_NUMBER="42" VERSION_NUMBER="1.2.0" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^exec fastlane release build_number:42 version_number:1.2.0$" "$BUNDLE_LOG" +} diff --git a/.github/workflows/ios-selfhosted-on-demand-build.yml b/.github/workflows/ios-selfhosted-on-demand-build.yml index d65d54d..536e05e 100644 --- a/.github/workflows/ios-selfhosted-on-demand-build.yml +++ b/.github/workflows/ios-selfhosted-on-demand-build.yml @@ -42,6 +42,14 @@ on: type: string required: false # Custom values + build_number: + description: 'Custom build number. Skips auto-increment from TestFlight.' + type: string + required: false + version_number: + description: 'Custom version number. Overrides default (1.0.0).' + type: string + required: false custom_values: description: 'Custom string that can contains values specified in your workflow file. Those values will be placed into environment variable. Example: "CUSTOM-1: 1; CUSTOM-2: 2"' type: string @@ -110,6 +118,8 @@ jobs: APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + build_number: ${{ inputs.build_number }} + version_number: ${{ inputs.version_number }} custom_values: ${{ inputs.custom_values }} - name: Upload IPA From bfbcf4125f3340f4a46767fa937b2340767b181a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Thu, 19 Feb 2026 09:49:02 +0000 Subject: [PATCH 2/4] refactor: Align version tag parsing with Fastlane behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop extracting build_number from version tags — it now only comes from the explicit build_number input. The tag regex accepts any suffix after the first dash (e.g. 1.2.3-beta, 1.2.3-rc1) and extracts only the x.y.z portion as version_number. Co-Authored-By: Claude Opus 4.6 --- .../actions/ios-fastlane-release/action.yml | 4 +-- .../ios-fastlane-release/parse-version-tag.sh | 7 ++--- .../test/test_parse-version-tag.bats | 30 ++++++++++++++----- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.github/actions/ios-fastlane-release/action.yml b/.github/actions/ios-fastlane-release/action.yml index ba463b3..8bf81bb 100644 --- a/.github/actions/ios-fastlane-release/action.yml +++ b/.github/actions/ios-fastlane-release/action.yml @@ -5,7 +5,7 @@ inputs: description: 'Match password' required: true version_number: - description: 'Version number (supports x.y.z or x.y.z-k format where k is build number)' + description: 'Version number (x.y.z format, any suffix after - is ignored)' required: false build_number: description: 'Custom build number. Skips auto-increment from TestFlight.' @@ -43,7 +43,7 @@ runs: env: MATCH_PASSWORD: ${{ inputs.match_password }} VERSION_NUMBER: ${{ steps.parse_version.outputs.version_number }} - BUILD_NUMBER: ${{ inputs.build_number || steps.parse_version.outputs.build_number }} + BUILD_NUMBER: ${{ inputs.build_number }} APP_STORE_CONNECT_API_KEY_KEY: ${{ inputs.app_store_connect_api_key_key }} APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ inputs.app_store_connect_api_key_key_id }} APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ inputs.app_store_connect_api_key_issuer_id }} diff --git a/.github/actions/ios-fastlane-release/parse-version-tag.sh b/.github/actions/ios-fastlane-release/parse-version-tag.sh index f243d47..b696e27 100755 --- a/.github/actions/ios-fastlane-release/parse-version-tag.sh +++ b/.github/actions/ios-fastlane-release/parse-version-tag.sh @@ -3,12 +3,9 @@ set -e TAG="$VERSION_TAG" -if [[ "$TAG" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-([0-9]+))?$ ]]; then +if [[ "$TAG" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-.*)?$ ]]; then echo "version_number=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" - if [[ -n "${BASH_REMATCH[3]}" ]]; then - echo "build_number=${BASH_REMATCH[3]}" >> "$GITHUB_OUTPUT" - fi else - echo "::error::Tag '$TAG' does not match expected format 'x.y.z' or 'x.y.z-k' (where k is an integer build number)" + echo "::error::Tag '$TAG' does not match expected format 'x.y.z' or 'x.y.z-*' (any suffix after - is ignored)" exit 1 fi diff --git a/.github/actions/ios-fastlane-release/test/test_parse-version-tag.bats b/.github/actions/ios-fastlane-release/test/test_parse-version-tag.bats index e688135..387c823 100644 --- a/.github/actions/ios-fastlane-release/test/test_parse-version-tag.bats +++ b/.github/actions/ios-fastlane-release/test/test_parse-version-tag.bats @@ -11,30 +11,46 @@ SCRIPT="$BATS_TEST_DIRNAME/../parse-version-tag.sh" ! grep -q "^build_number=" "$GITHUB_OUTPUT" } -@test "parses version tag with build number 1.2.3-42" { +@test "parses version tag with suffix 1.2.3-42" { VERSION_TAG="1.2.3-42" run bash "$SCRIPT" [ "$status" -eq 0 ] grep -q "^version_number=1.2.3$" "$GITHUB_OUTPUT" - grep -q "^build_number=42$" "$GITHUB_OUTPUT" + ! grep -q "^build_number=" "$GITHUB_OUTPUT" } @test "parses large version numbers 10.20.30-999" { VERSION_TAG="10.20.30-999" run bash "$SCRIPT" [ "$status" -eq 0 ] grep -q "^version_number=10.20.30$" "$GITHUB_OUTPUT" - grep -q "^build_number=999$" "$GITHUB_OUTPUT" + ! grep -q "^build_number=" "$GITHUB_OUTPUT" } -@test "parses build number zero 1.0.0-0" { +@test "parses version tag with zero suffix 1.0.0-0" { VERSION_TAG="1.0.0-0" run bash "$SCRIPT" [ "$status" -eq 0 ] grep -q "^version_number=1.0.0$" "$GITHUB_OUTPUT" - grep -q "^build_number=0$" "$GITHUB_OUTPUT" + ! grep -q "^build_number=" "$GITHUB_OUTPUT" } -@test "rejects semver pre-release tag 1.0.0-beta.1" { +@test "parses tag with pre-release suffix 1.0.0-beta.1" { VERSION_TAG="1.0.0-beta.1" run bash "$SCRIPT" - [ "$status" -eq 1 ] + [ "$status" -eq 0 ] + grep -q "^version_number=1.0.0$" "$GITHUB_OUTPUT" + ! grep -q "^build_number=" "$GITHUB_OUTPUT" +} + +@test "parses tag with trailing dash 1.2.3-" { + VERSION_TAG="1.2.3-" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^version_number=1.2.3$" "$GITHUB_OUTPUT" + ! grep -q "^build_number=" "$GITHUB_OUTPUT" +} + +@test "parses tag with text suffix 2.0.0-rc1" { + VERSION_TAG="2.0.0-rc1" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -q "^version_number=2.0.0$" "$GITHUB_OUTPUT" + ! grep -q "^build_number=" "$GITHUB_OUTPUT" } @test "rejects v-prefixed tag v1.0.0" { From 8ae7ebd1f11f6126bf2df3830751be2ee2f4ee1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Thu, 19 Feb 2026 10:12:03 +0000 Subject: [PATCH 3/4] docs: Update CLAUDE.md with version tag parsing and release BATS tests Document the relaxed version tag format (x.y.z-* suffix ignored) and that build_number is never derived from tags. Add release action BATS test commands to the testing section. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7968b0c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,174 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +This repository contains reusable GitHub Actions workflows and composite actions for iOS, Android, and Kotlin Multiplatform (KMP) projects. These workflows are designed to be referenced from other projects using the pattern: + +```yaml +jobs: + job_name: + uses: futuredapp/.github/.github/workflows/{platform}-{runner}-{action}.yml@{version} +``` + +## Project Structure + +``` +.github/ +├── actions/ # Composite actions (reusable action components) +│ ├── android-* # Android-specific actions +│ ├── ios-* # iOS-specific actions +│ ├── kmp-* # Kotlin Multiplatform actions +│ └── universal-* # Platform-agnostic actions +└── workflows/ # Reusable workflows + ├── android-* # Android workflows + ├── ios-* # iOS workflows + ├── kmp-* # KMP workflows + └── universal-* # Platform-agnostic workflows +``` + +## Key Architecture Patterns + +### Workflow Composition +- **Reusable Workflows** (in `.github/workflows/`): Entry points called by consumer projects, handle secrets and high-level orchestration +- **Composite Actions** (in `.github/actions/`): Modular building blocks that workflows use, contain the actual implementation logic +- Actions are referenced using `futuredapp/.github/.github/actions/{action-name}@main` pattern + +### Change Detection System +The `universal-detect-changes-and-generate-changelog` action is critical infrastructure: +- Uses GitHub Actions cache to track the last successfully built commit +- Determines if builds should be skipped based on changes since last build +- Generates changelogs from merged branch names +- Composed of three modular bash scripts: + - `cache-keys.sh`: Handles cache key generation with custom prefixes + - `determine-range.sh`: Determines commit range and skip build logic + - `generate-changelog.sh`: Formats changelog and extracts merged branches +- Used by nightly build workflows to avoid rebuilding unchanged code + +### Platform Detection for KMP +The `kmp-detect-changes` action detects whether iOS or Android files changed: +- Uses `dorny/paths-filter` to identify which platform(s) need building +- iOS files: All files except those in `androidApp/` +- Android files: All files except those in `iosApp/` + +### Runner Types +- **Cloud runners**: Use `ubuntu-latest` for cost efficiency (Android, universal tasks) +- **Self-hosted runners**: Required for iOS builds (macOS with Xcode) +- Runner labels are configurable via `runner_label` input (default: `self-hosted`) + +## Common Commands + +### Testing Bash Scripts +Several actions include BATS (Bash Automated Testing System) tests: + +```bash +# Install BATS (if not already installed) +brew install bats-core # macOS +apt-get install bats # Ubuntu/Debian + +# Run changelog action tests +cd .github/actions/universal-detect-changes-and-generate-changelog/test +./run_tests.sh + +# Run specific changelog test file +bats test_cache-keys.bats +bats test_determine-range.bats +bats test_generate-changelog.bats + +# Run release action version tag parsing tests +cd .github/actions/ios-fastlane-release/test +bats test_parse-version-tag.bats +``` + +### Linting Workflows +```bash +# Download and run actionlint +bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) +./actionlint -color +``` + +### Android Actions +Actions use Gradle tasks passed as inputs: +- Lint: `./gradlew --continue {LINT_GRADLE_TASK}` +- Test: `./gradlew --continue {TEST_GRADLE_TASK}` +- Build: `./gradlew {BUNDLE_GRADLE_TASK}` (for releases) + +### iOS Actions +iOS actions wrap Fastlane scripts: +- Test: Runs Fastlane test lane +- Beta: Runs Fastlane beta lane (uploads to TestFlight) +- Release: Runs Fastlane release lane (submits to App Store) + - The `version_number` input is parsed by `parse-version-tag.sh`: accepts `x.y.z` or `x.y.z-*` (any suffix after `-` is ignored, only `x.y.z` is extracted) + - `build_number` is only set via the explicit `build_number` input (or Fastlane auto-increment); it is never derived from the version tag + +All iOS actions support: +- `custom_values`: Environment variables in format "KEY1: value1; KEY2: value2" +- `custom_build_path`: Override default build output location + +## Workflow Types by Platform + +### iOS (Self-hosted) +- `ios-selfhosted-test`: Lint and test PRs +- `ios-selfhosted-nightly-build`: Automated nightly builds with changelog generation +- `ios-selfhosted-on-demand-build`: Manual builds triggered on-demand +- `ios-selfhosted-release`: Release builds for App Store submission + +### iOS KMP (Self-hosted) +- `ios-kmp-selfhosted-test`: Lint and test PRs (KMP variant) +- `ios-kmp-selfhosted-build`: Enterprise builds with KMP shared code +- `ios-kmp-selfhosted-release`: Release builds with KMP shared code + +### Android (Cloud) +- `android-cloud-check`: Unit tests and lint checks on PRs +- `android-cloud-nightly-build`: Automated nightly builds with Firebase distribution +- `android-cloud-release-firebaseAppDistribution`: QA snapshot releases to Firebase +- `android-cloud-release-googlePlay`: Production releases to Google Play +- `android-cloud-generate-baseline-profiles`: Generate and PR baseline profiles + +### KMP (Cloud) +- `kmp-cloud-detect-changes`: Detect iOS/Android changes for conditional execution +- `kmp-combined-nightly-build`: Nightly builds for both iOS and Android + +### Universal +- `workflows-lint`: Lint all workflow files using actionlint +- `universal-cloud-backup`: Backup current ref to remote repository (cloud runner) +- `universal-selfhosted-backup`: Backup current ref to remote repository (self-hosted) + +## Important Conventions + +### Secrets Management +- iOS workflows require App Store Connect API keys and Match password +- Android workflows require keystore passwords and Google Play service account JSON +- iOS actions support injecting secrets into `.xcconfig` files via `ios-export-secrets` action +- Android actions write secrets to `secrets.properties` file for Gradle pickup + +### Build Artifacts +- iOS workflows upload `.ipa` and `.app.dSYM.zip` files to GitHub artifacts +- Android workflows upload `.aab` bundles directly to Google Play +- Artifacts are stored in `build_output/` directory + +### Custom Values Format +Many workflows accept `custom_values` input for environment variables: +``` +"KEY1: value1; KEY2: value2; KEY3: value3" +``` + +### Changelog Generation +- Changelogs are generated from merged branch names +- Falls back to 24-hour lookback window if no previous build is found +- Supports custom cache key prefixes for multi-variant builds +- Format: Lists merged branches since last successful build + +### Concurrency Control +Test workflows use concurrency groups to cancel outdated runs: +```yaml +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true +``` + +## Maintainers + +- Jakub Marek () - GitHub: @jmarek41 +- Matej Semančík () - GitHub: @matejsemancik From d4eb83d9a182600b948c07e928499411c0477337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 23 Feb 2026 09:31:28 +0000 Subject: [PATCH 4/4] refactor: Use bash arrays for FASTLANE_ARGS in beta.sh and release.sh Replace verbose string concatenation with array-based arg building, removing the shellcheck disable directive. Addresses PR #80 readability feedback. Co-Authored-By: Claude Opus 4.6 --- .github/actions/ios-fastlane-beta/beta.sh | 13 ++++--------- .github/actions/ios-fastlane-release/release.sh | 13 ++++--------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/.github/actions/ios-fastlane-beta/beta.sh b/.github/actions/ios-fastlane-beta/beta.sh index 9464cdb..88e439c 100755 --- a/.github/actions/ios-fastlane-beta/beta.sh +++ b/.github/actions/ios-fastlane-beta/beta.sh @@ -17,14 +17,9 @@ if [ -n "$CUSTOM_BUILD_PATH" ]; then fi # Build fastlane arguments from optional overrides -FASTLANE_ARGS="" -if [ -n "$BUILD_NUMBER" ]; then - FASTLANE_ARGS="$FASTLANE_ARGS build_number:$BUILD_NUMBER" -fi -if [ -n "$VERSION_NUMBER" ]; then - FASTLANE_ARGS="$FASTLANE_ARGS version_number:$VERSION_NUMBER" -fi +fastlane_args=() +[ -n "$BUILD_NUMBER" ] && fastlane_args+=("build_number:$BUILD_NUMBER") +[ -n "$VERSION_NUMBER" ] && fastlane_args+=("version_number:$VERSION_NUMBER") # Environment variables are already set by action.yml -# shellcheck disable=SC2086 -bundle exec fastlane beta $FASTLANE_ARGS +bundle exec fastlane beta "${fastlane_args[@]}" diff --git a/.github/actions/ios-fastlane-release/release.sh b/.github/actions/ios-fastlane-release/release.sh index c1673f3..5eabeea 100755 --- a/.github/actions/ios-fastlane-release/release.sh +++ b/.github/actions/ios-fastlane-release/release.sh @@ -17,14 +17,9 @@ if [ -n "$CUSTOM_BUILD_PATH" ]; then fi # Build fastlane arguments from optional overrides -FASTLANE_ARGS="" -if [ -n "$BUILD_NUMBER" ]; then - FASTLANE_ARGS="$FASTLANE_ARGS build_number:$BUILD_NUMBER" -fi -if [ -n "$VERSION_NUMBER" ]; then - FASTLANE_ARGS="$FASTLANE_ARGS version_number:$VERSION_NUMBER" -fi +fastlane_args=() +[ -n "$BUILD_NUMBER" ] && fastlane_args+=("build_number:$BUILD_NUMBER") +[ -n "$VERSION_NUMBER" ] && fastlane_args+=("version_number:$VERSION_NUMBER") # Environment variables are already set by action.yml -# shellcheck disable=SC2086 -bundle exec fastlane release $FASTLANE_ARGS +bundle exec fastlane release "${fastlane_args[@]}"