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..88e439c 100755 --- a/.github/actions/ios-fastlane-beta/beta.sh +++ b/.github/actions/ios-fastlane-beta/beta.sh @@ -16,5 +16,10 @@ if [ -n "$CUSTOM_BUILD_PATH" ]; then cd $GITHUB_WORKSPACE/$CUSTOM_BUILD_PATH fi +# Build fastlane arguments from optional overrides +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 -bundle exec fastlane beta +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..8bf81bb 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 (x.y.z format, any suffix after - is ignored)' + 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 }} 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..b696e27 --- /dev/null +++ b/.github/actions/ios-fastlane-release/parse-version-tag.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +TAG="$VERSION_TAG" + +if [[ "$TAG" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-.*)?$ ]]; then + echo "version_number=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" +else + 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/release.sh b/.github/actions/ios-fastlane-release/release.sh index 2d9bf0f..5eabeea 100755 --- a/.github/actions/ios-fastlane-release/release.sh +++ b/.github/actions/ios-fastlane-release/release.sh @@ -16,5 +16,10 @@ if [ -n "$CUSTOM_BUILD_PATH" ]; then cd $GITHUB_WORKSPACE/$CUSTOM_BUILD_PATH fi +# Build fastlane arguments from optional overrides +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 -bundle exec fastlane release +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..387c823 --- /dev/null +++ b/.github/actions/ios-fastlane-release/test/test_parse-version-tag.bats @@ -0,0 +1,69 @@ +#!/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 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=" "$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=" "$GITHUB_OUTPUT" +} + +@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=" "$GITHUB_OUTPUT" +} + +@test "parses tag with pre-release suffix 1.0.0-beta.1" { + VERSION_TAG="1.0.0-beta.1" run bash "$SCRIPT" + [ "$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" { + 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 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