From b100fc1ec5079657e5fb2723c10e094f9f579fc0 Mon Sep 17 00:00:00 2001 From: JeffG <202880+erskingardner@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:34:19 +0200 Subject: [PATCH] Add Fastlane release scaffolding (#601) * Add Fastlane release scaffolding * chore: document release flow and tag guard * fix: address release automation review comments * fix: harden release version parsing * fix: resolve release review follow-ups * fix: restore Android staging application id --- .gitignore | 9 + Gemfile | 3 + Gemfile.lock | 238 ++++++++++++++++++ RELEASE.md | 212 ++++++++++++++++ fastlane/Fastfile | 202 +++++++++++++++ justfile | 72 ++++++ scripts/validate_release_version.sh | 148 +++++++++++ .../scripts/release_automation_config_test.sh | 64 +++++ test/scripts/validate_release_version_test.sh | 122 +++++++++ zapstore.yaml | 1 + 10 files changed, 1071 insertions(+) create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 RELEASE.md create mode 100644 fastlane/Fastfile create mode 100755 scripts/validate_release_version.sh create mode 100755 test/scripts/release_automation_config_test.sh create mode 100755 test/scripts/validate_release_version_test.sh diff --git a/.gitignore b/.gitignore index caf1430..1de9de5 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,15 @@ opencode.json .opencode/ .cursor/ +# Fastlane local credentials and generated output +/fastlane/*.json +/fastlane/*.p8 +/fastlane/README.md +/fastlane/report.xml +/fastlane/Preview.html +/fastlane/screenshots/ +/fastlane/test_output/ + # Flutter DevTools devtools_options.yaml # Widgetbook diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..2fabf60 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gem 'fastlane', '~> 2.233' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..a720489 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,238 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1241.0) + aws-sdk-core (3.246.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.124.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.220.0) + aws-sdk-core (~> 3, >= 3.244.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + benchmark (0.5.0) + bigdecimal (4.1.2) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.5) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.233.0) + CFPropertyList (>= 2.3, < 4.0.0) + abbrev (~> 0.1.2) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2.0) + benchmark (>= 0.1.0) + bundler (>= 1.17.3, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, <= 2.1.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3.0) + naturally (~> 2.2) + nkf (~> 0.2.0) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.99.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.62.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.1.1) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.59.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.19.4) + jwt (2.10.2) + base64 + logger (1.7.0) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.20.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + nkf (0.2.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + public_suffix (7.0.5) + rake (13.4.2) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.4.1) + rexml (3.4.4) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + fastlane (~> 2.233) + +BUNDLED WITH + 2.6.5 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..bbe0565 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,212 @@ +# Release Automation + +This repo uses Fastlane as a thin wrapper around the existing Flutter, Rust, and +shell build scripts. + +## Current Scope + +The first release automation layer is build-only: + +- validates that `pubspec.yaml` has a release version with a build number +- optionally validates that a git tag matches the pubspec version +- builds staging Android split APKs and AAB for `org.parres.whitenoise.staging` +- builds production Android split APKs and AAB for `org.parres.whitenoise` +- builds staging iOS IPA for `dev.ipf.whitenoise.staging` +- builds production iOS IPA for `org.parres.whitenoise` +- stages artifacts under `build/releases/v+//` + +Staging and production are separate apps on both Android and iOS. Current iOS +publishing target for both apps is App Store Connect/TestFlight. Store release +submission, GitHub Releases, Zap Store, and Play Store upload lanes will be added +after signing and account setup are stable. + +Current app IDs: + +- Production Android: `org.parres.whitenoise` +- Production iOS: `org.parres.whitenoise` +- Staging Android: `org.parres.whitenoise.staging` +- Staging iOS: `dev.ipf.whitenoise.staging` + +If production moves under the IPF namespace later, the expected target would be +`dev.ipf.whitenoise` for production and `dev.ipf.whitenoise.staging` for staging +on both platforms. + +## Setup + +Install the Fastlane bundle: + +```bash +bundle install +``` + +## Manual Release Runbook + +The current release flow is local build automation plus manual uploads. + +Set the release version: + +```bash +export RELEASE_VERSION=2026.4.28 +export RELEASE_BUILD=23 +export RELEASE_TAG=v2026.4.28+23 +``` + +1. Update `pubspec.yaml`. + + ```yaml + version: 2026.4.28+23 + ``` + +2. Update `CHANGELOG.md`. + + Move the current `Unreleased` content into a dated release section and add a + fresh `Unreleased` section above it: + + ```md + ## Unreleased + + ### Added + + ### Changed + + ### Deprecated + + ### Removed + + ### Fixed + + ### Security + + ## [2026.4.28] - 2026-04-28 + ``` + +3. Run the pre-release checks. + + ```bash + just precommit-check + just test-release-scripts + just release-doctor + ``` + +4. Commit the release prep. + + ```bash + git add pubspec.yaml CHANGELOG.md RELEASE.md + git commit -m "chore: prepare ${RELEASE_TAG}" + ``` + +5. Create the annotated tag on the release commit. + + ```bash + git tag -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" + ``` + +6. Validate the tag guard. + + ```bash + just release-doctor "${RELEASE_TAG}" + ``` + + This fails unless `pubspec.yaml` matches the tag, the tag exists, and the tag + points at the current `HEAD`. + +7. Build staging and production artifacts. + + ```bash + just release-build-all "${RELEASE_TAG}" + ``` + + Release artifacts are staged under: + + ```text + build/releases/v2026.4.28+23/staging/android/ + build/releases/v2026.4.28+23/staging/ios/ + build/releases/v2026.4.28+23/production/android/ + build/releases/v2026.4.28+23/production/ios/ + ``` + +8. Upload artifacts manually. + + Current upload targets: + + - GitHub Release: production APKs + - Zap Store: production arm64 APK selected by `zapstore.yaml` + - App Store Connect/TestFlight: staging IPA and production IPA + - Play Store: production AAB after Play setup is complete + - Play Store staging app: staging AAB after Play setup is complete + +9. Push the release commit and tag after the build is verified. + + ```bash + git push origin HEAD + git push origin "${RELEASE_TAG}" + ``` + +## Commands + +Validate the current release version: + +```bash +just release-doctor +``` + +Validate the current release version against a tag that exists on `HEAD`: + +```bash +just release-doctor v2026.3.23+22 +``` + +When a tag is passed, the validator requires all of these to be true: + +- `pubspec.yaml` contains `version: 2026.3.23+22` +- the tag name is `v2026.3.23+22` +- the tag exists in git +- the tag points at the current `HEAD` + +Build the staging app release artifacts: + +```bash +just release-build-staging +``` + +Build the production app release artifacts: + +```bash +just release-build-production +``` + +Build both staging and production app release artifacts: + +```bash +just release-build-all v2026.3.23+22 +``` + +Build only staging Android artifacts: + +```bash +just release-build-android-staging +``` + +Build only production Android artifacts: + +```bash +just release-build-android-production +``` + +Build only staging iOS artifacts: + +```bash +just release-build-ios-staging +``` + +Build only production iOS artifacts: + +```bash +just release-build-ios-production +``` + +## Secrets + +Do not commit store credentials, App Store Connect API keys, provisioning files, +or Android keystores. Fastlane local credential files such as `.p8` and `.json` +files under `fastlane/` are ignored by git. diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..de072b3 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,202 @@ +require 'fileutils' +require 'open3' +require 'shellwords' + +opt_out_usage + +ROOT = File.expand_path('..', __dir__) +FLAVORS = { + 'staging' => { + artifact_prefix: 'whitenoise-staging', + android_package_name: 'org.parres.whitenoise.staging', + ios_app_identifier: 'dev.ipf.whitenoise.staging', + }, + 'production' => { + artifact_prefix: 'whitenoise', + android_package_name: 'org.parres.whitenoise', + ios_app_identifier: 'org.parres.whitenoise', + }, +}.freeze + +def run_from_root(parts) + sh("cd #{Shellwords.escape(ROOT)} && #{parts.shelljoin}") +end + +def flavor_config(flavor) + config = FLAVORS[flavor.to_s] + UI.user_error!("Unknown release flavor: #{flavor}") unless config + config +end + +def release_tag(options = {}) + tag = options[:tag].to_s.strip + return tag unless tag.empty? + + tag = ENV.fetch('RELEASE_TAG', '').to_s.strip + return tag unless tag.empty? + + return ENV.fetch('GITHUB_REF_NAME', '').to_s.strip if ENV['GITHUB_REF_TYPE'] == 'tag' + + '' +end + +def release_info(options = {}) + tag = release_tag(options) + + command = ['./scripts/validate_release_version.sh'] + command += ['--tag', tag] unless tag.empty? + + output, error, status = Open3.capture3(*command, chdir: ROOT) + UI.user_error!(error.strip.empty? ? output.strip : error.strip) unless status.success? + + info = {} + output.lines.each do |line| + key, value = line.strip.split('=', 2) + info[key] = value if key && value + end + + UI.user_error!('Release version could not be resolved') unless info['full_version'] + UI.user_error!('Release build number could not be resolved') unless info['build_number'] + + info +end + +def flutter_build_args(info) + ['--build-name', info['version_name'], '--build-number', info['build_number']] +end + +def release_output_dir(info, flavor, *parts) + File.join(ROOT, 'build', 'releases', "v#{info['full_version']}", flavor.to_s, *parts) +end + +def stage_artifact(source, destination_dir, destination_name) + UI.user_error!("Missing artifact: #{source}") unless File.file?(source) + + FileUtils.mkdir_p(destination_dir) + destination = File.join(destination_dir, destination_name) + FileUtils.cp(source, destination) + UI.message("Staged #{destination}") + destination +end + +def latest_ipa + Dir[File.join(ROOT, 'build', 'ios', 'ipa', '*.ipa')].max_by { |path| File.mtime(path) } +end + +def android_aab(flavor) + candidates = Dir[File.join(ROOT, 'build', 'app', 'outputs', 'bundle', '**', "*#{flavor}*.aab")] + candidates.max_by { |path| File.mtime(path) } +end + +def require_macos! + UI.user_error!('iOS release builds require macOS') unless RUBY_PLATFORM.include?('darwin') +end + +def build_android_flavor(flavor, options, build_native: true) + info = release_info(options) + config = flavor_config(flavor) + args = flutter_build_args(info) + + run_from_root(['./scripts/build_android.sh']) if build_native + run_from_root(['flutter', 'build', 'apk', '--flavor', flavor, '--split-per-abi'] + args) + run_from_root(['flutter', 'build', 'appbundle', '--flavor', flavor] + args) + + apk_dir = File.join(ROOT, 'build', 'app', 'outputs', 'flutter-apk') + output_dir = release_output_dir(info, flavor, 'android') + + %w[arm64-v8a armeabi-v7a x86_64].each do |abi| + source = File.join(apk_dir, "app-#{abi}-#{flavor}-release.apk") + unless File.file?(source) + next UI.important("Skipping missing optional APK: #{source}") if abi == 'armeabi-v7a' + + UI.user_error!("Required APK was not found: #{source}") + end + + stage_artifact(source, output_dir, "#{config[:artifact_prefix]}-#{info['version_name']}-#{abi}.apk") + end + + source = android_aab(flavor) + UI.user_error!("#{flavor.capitalize} AAB was not found") unless source + + stage_artifact(source, output_dir, "#{config[:artifact_prefix]}-#{info['version_name']}.aab") +end + +def build_ios_flavor(flavor, options, build_native: true) + require_macos! + info = release_info(options) + config = flavor_config(flavor) + + run_from_root(['./scripts/build_ios.sh']) if build_native + run_from_root( + ['flutter', 'build', 'ipa', '--flavor', flavor, '--export-method', 'app-store'] + + flutter_build_args(info), + ) + + source = latest_ipa + UI.user_error!("#{flavor.capitalize} IPA was not found") unless source + + stage_artifact( + source, + release_output_dir(info, flavor, 'ios'), + "#{config[:artifact_prefix]}-#{info['full_version']}.ipa", + ) +end + +def build_channel_release(flavor, options) + build_android_flavor(flavor, options) + + if RUBY_PLATFORM.include?('darwin') + build_ios_flavor(flavor, options) + else + UI.important("Skipping #{flavor} iOS artifacts because this machine is not macOS") + end +end + +desc 'Validate release version metadata' +lane :release_doctor do |options| + info = release_info(options) + UI.success("Release #{info['full_version']} is ready to build") +end + +desc 'Build staging Android APKs and AAB' +lane :build_android_staging do |options| + build_android_flavor('staging', options) +end + +desc 'Build production Android APKs and AAB' +lane :build_android_production do |options| + build_android_flavor('production', options) +end + +desc 'Build staging iOS IPA for App Store Connect/TestFlight' +lane :build_ios_staging do |options| + build_ios_flavor('staging', options) +end + +desc 'Build production iOS IPA for App Store Connect/TestFlight' +lane :build_ios_production do |options| + build_ios_flavor('production', options) +end + +desc 'Build staging app release artifacts' +lane :build_staging_release do |options| + build_channel_release('staging', options) +end + +desc 'Build production app release artifacts' +lane :build_production_release do |options| + build_channel_release('production', options) +end + +desc 'Build staging and production release artifacts' +lane :build_all_release_artifacts do |options| + build_android_flavor('staging', options) + build_android_flavor('production', options, build_native: false) + + if RUBY_PLATFORM.include?('darwin') + build_ios_flavor('staging', options) + build_ios_flavor('production', options, build_native: false) + else + UI.important('Skipping iOS artifacts because this machine is not macOS') + end +end diff --git a/justfile b/justfile index 8ebdb7a..1184a79 100644 --- a/justfile +++ b/justfile @@ -186,6 +186,10 @@ validate-locales-keys: @echo "🔍 Validating l10n keys..." ./scripts/validate-locales-keys.sh +test-release-scripts: + @bash test/scripts/validate_release_version_test.sh + @bash test/scripts/release_automation_config_test.sh + # ============================================================================== # CLEANING # ============================================================================== @@ -278,6 +282,74 @@ build-release-android: build-release-ios: ./scripts/build_release.sh --ios +# ============================================================================== +# RELEASE AUTOMATION +# ============================================================================== + +# Validate release version metadata, optionally against a git tag +release-doctor tag="": + @if [ -n "{{tag}}" ]; then \ + bundle exec fastlane release_doctor tag:"{{tag}}"; \ + else \ + bundle exec fastlane release_doctor; \ + fi + +# Build staging Android release artifacts with Fastlane +release-build-android-staging tag="": + @if [ -n "{{tag}}" ]; then \ + bundle exec fastlane build_android_staging tag:"{{tag}}"; \ + else \ + bundle exec fastlane build_android_staging; \ + fi + +# Build production Android release artifacts with Fastlane +release-build-android-production tag="": + @if [ -n "{{tag}}" ]; then \ + bundle exec fastlane build_android_production tag:"{{tag}}"; \ + else \ + bundle exec fastlane build_android_production; \ + fi + +# Build staging iOS IPA for App Store Connect/TestFlight with Fastlane +release-build-ios-staging tag="": + @if [ -n "{{tag}}" ]; then \ + bundle exec fastlane build_ios_staging tag:"{{tag}}"; \ + else \ + bundle exec fastlane build_ios_staging; \ + fi + +# Build production iOS IPA for App Store Connect/TestFlight with Fastlane +release-build-ios-production tag="": + @if [ -n "{{tag}}" ]; then \ + bundle exec fastlane build_ios_production tag:"{{tag}}"; \ + else \ + bundle exec fastlane build_ios_production; \ + fi + +# Build staging app release artifacts with Fastlane +release-build-staging tag="": + @if [ -n "{{tag}}" ]; then \ + bundle exec fastlane build_staging_release tag:"{{tag}}"; \ + else \ + bundle exec fastlane build_staging_release; \ + fi + +# Build production app release artifacts with Fastlane +release-build-production tag="": + @if [ -n "{{tag}}" ]; then \ + bundle exec fastlane build_production_release tag:"{{tag}}"; \ + else \ + bundle exec fastlane build_production_release; \ + fi + +# Build staging and production release artifacts with Fastlane +release-build-all tag="": + @if [ -n "{{tag}}" ]; then \ + bundle exec fastlane build_all_release_artifacts tag:"{{tag}}"; \ + else \ + bundle exec fastlane build_all_release_artifacts; \ + fi + # ============================================================================== # BUILDING - iOS # ============================================================================== diff --git a/scripts/validate_release_version.sh b/scripts/validate_release_version.sh new file mode 100755 index 0000000..7a9d365 --- /dev/null +++ b/scripts/validate_release_version.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PUBSPEC="pubspec.yaml" +TAG="" +if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then + TAG="${GITHUB_REF_NAME:-}" +fi +GITHUB_OUTPUT_FILE="${GITHUB_OUTPUT:-}" +REPO="." + +usage() { + cat <<'EOF' +Usage: scripts/validate_release_version.sh [--pubspec PATH] [--repo PATH] [--tag TAG] [--github-output PATH] + +Validates that the Flutter pubspec version has a build number and, when a tag +is provided, that the tag matches the pubspec version and points at HEAD. +EOF +} + +require_option_value() { + local option="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == --* ]]; then + echo "Missing value for $option" >&2 + usage >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --pubspec) + require_option_value "$1" "${2:-}" + PUBSPEC="$2" + shift 2 + ;; + --repo) + require_option_value "$1" "${2:-}" + REPO="$2" + shift 2 + ;; + --tag) + require_option_value "$1" "${2:-}" + TAG="$2" + shift 2 + ;; + --github-output) + require_option_value "$1" "${2:-}" + GITHUB_OUTPUT_FILE="$2" + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$PUBSPEC" = /* ]]; then + RESOLVED_PUBSPEC="$PUBSPEC" +else + RESOLVED_PUBSPEC="$REPO/$PUBSPEC" +fi + +if [[ ! -f "$RESOLVED_PUBSPEC" ]]; then + echo "Pubspec not found: $RESOLVED_PUBSPEC" >&2 + exit 1 +fi + +VERSION_LINE="$(grep -E '^[[:space:]]*version:[[:space:]]*[^[:space:]]+' "$RESOLVED_PUBSPEC" | head -n 1 || true)" +if [[ -z "$VERSION_LINE" ]]; then + echo "Could not find version in $RESOLVED_PUBSPEC" >&2 + exit 1 +fi + +FULL_VERSION="$( + printf '%s\n' "$VERSION_LINE" | + sed -E 's/^[[:space:]]*version:[[:space:]]*//' | + sed -E 's/[[:space:]]*#.*$//' | + sed -E 's/^[[:space:]]*["'\'']?//; s/["'\'']?[[:space:]]*$//' +)" +if [[ "$FULL_VERSION" != *+* ]]; then + echo "Release version must include a build number, e.g. 2026.4.28+23" >&2 + exit 1 +fi + +VERSION_NAME="${FULL_VERSION%%+*}" +BUILD_NUMBER="${FULL_VERSION##*+}" + +if [[ -z "$VERSION_NAME" || -z "$BUILD_NUMBER" || "$VERSION_NAME" == "$BUILD_NUMBER" ]]; then + echo "Invalid release version: $FULL_VERSION" >&2 + exit 1 +fi + +if ! [[ "$BUILD_NUMBER" =~ ^[0-9]+$ ]]; then + echo "Build number must be numeric: $BUILD_NUMBER" >&2 + exit 1 +fi + +if [[ -n "$TAG" ]]; then + TAG_NAME="${TAG#refs/tags/}" + TAG_VERSION="${TAG_NAME#v}" + if [[ "$TAG_VERSION" != "$FULL_VERSION" ]]; then + echo "Tag $TAG_NAME does not match pubspec version $FULL_VERSION" >&2 + exit 1 + fi + + if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Tag validation requires a git repository: $REPO" >&2 + exit 1 + fi + + if ! TAG_COMMIT="$(git -C "$REPO" rev-parse --verify --quiet "$TAG_NAME^{commit}" 2>/dev/null)"; then + echo "Tag $TAG_NAME does not exist in $REPO" >&2 + exit 1 + fi + + HEAD_COMMIT="$(git -C "$REPO" rev-parse HEAD)" + if [[ "$TAG_COMMIT" != "$HEAD_COMMIT" ]]; then + echo "Tag $TAG_NAME does not point at HEAD" >&2 + exit 1 + fi +fi + +emit() { + local key="$1" + local value="$2" + printf '%s=%s\n' "$key" "$value" + if [[ -n "$GITHUB_OUTPUT_FILE" ]]; then + printf '%s=%s\n' "$key" "$value" >>"$GITHUB_OUTPUT_FILE" + fi +} + +emit "full_version" "$FULL_VERSION" +emit "version_name" "$VERSION_NAME" +emit "build_number" "$BUILD_NUMBER" +if [[ -n "$TAG" ]]; then + emit "tag" "$TAG_NAME" + emit "tag_commit" "$TAG_COMMIT" +fi diff --git a/test/scripts/release_automation_config_test.sh b/test/scripts/release_automation_config_test.sh new file mode 100755 index 0000000..7533bde --- /dev/null +++ b/test/scripts/release_automation_config_test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +assert_contains() { + local haystack="$1" + local pattern="$2" + if ! printf '%s\n' "$haystack" | grep -Eq "^${pattern}$"; then + fail "expected output to contain line matching regex '$pattern'" + fi +} + +just_list="$(just --list --unsorted)" +assert_contains "$just_list" '[[:space:]]*release-build-staging[[:space:]].*' +assert_contains "$just_list" '[[:space:]]*release-build-production[[:space:]].*' +assert_contains "$just_list" '[[:space:]]*release-build-android-staging[[:space:]].*' +assert_contains "$just_list" '[[:space:]]*release-build-android-production[[:space:]].*' +assert_contains "$just_list" '[[:space:]]*release-build-ios-staging[[:space:]].*' +assert_contains "$just_list" '[[:space:]]*release-build-ios-production[[:space:]].*' + +fastfile="$(cat fastlane/Fastfile)" +assert_contains "$fastfile" 'lane[[:space:]]+:build_android_staging[[:space:]]+do.*' +assert_contains "$fastfile" 'lane[[:space:]]+:build_android_production[[:space:]]+do.*' +assert_contains "$fastfile" 'lane[[:space:]]+:build_ios_staging[[:space:]]+do.*' +assert_contains "$fastfile" 'lane[[:space:]]+:build_ios_production[[:space:]]+do.*' +assert_contains "$fastfile" 'lane[[:space:]]+:build_staging_release[[:space:]]+do.*' +assert_contains "$fastfile" 'lane[[:space:]]+:build_production_release[[:space:]]+do.*' +assert_contains "$fastfile" "[[:space:]]*android_package_name: 'org\\.parres\\.whitenoise\\.staging'," +assert_contains "$fastfile" "[[:space:]]*android_package_name: 'org\\.parres\\.whitenoise'," +assert_contains "$fastfile" "[[:space:]]*ios_app_identifier: 'dev\\.ipf\\.whitenoise\\.staging'," +assert_contains "$fastfile" "[[:space:]]*ios_app_identifier: 'org\\.parres\\.whitenoise'," + +android_gradle="$(cat android/app/build.gradle.kts)" +assert_contains "$android_gradle" '[[:space:]]*applicationIdSuffix = "\.staging"' +assert_contains "$android_gradle" '[[:space:]]*applicationId = "org\.parres\.whitenoise"' + +ruby <<'RUBY' +require 'yaml' + +config = YAML.safe_load( + File.read('zapstore.yaml'), + permitted_classes: [], + permitted_symbols: [], + aliases: false, +) +pattern = config.fetch('match') +regex = Regexp.new(pattern) + +production_apk = 'whitenoise-2026.3.23-arm64-v8a.apk' +staging_apk = 'whitenoise-staging-2026.3.23-arm64-v8a.apk' + +raise 'Zap Store match must include production arm64 APK' unless regex.match?(production_apk) +raise 'Zap Store match must exclude staging APK' if regex.match?(staging_apk) +RUBY + +echo "release_automation_config_test passed" diff --git a/test/scripts/validate_release_version_test.sh b/test/scripts/validate_release_version_test.sh new file mode 100755 index 0000000..e4a4dbe --- /dev/null +++ b/test/scripts/validate_release_version_test.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/validate_release_version.sh" + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT +RELEASE_REPO="$TMPDIR/repo" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +assert_contains() { + local haystack="$1" + local needle="$2" + if [[ "$haystack" != *"$needle"* ]]; then + fail "expected output to contain '$needle'; got: $haystack" + fi +} + +write_pubspec() { + local version="$1" + cat >"$RELEASE_REPO/pubspec.yaml" <"$RELEASE_REPO/pubspec.yaml" <"$TMPDIR/out" 2>"$TMPDIR/err"; then + fail "expected mismatched tag to fail" +fi +assert_contains "$(cat "$TMPDIR/err")" "does not match pubspec version" + +git -C "$RELEASE_REPO" tag -d v2026.4.28+23 >/dev/null +if "$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --repo "$RELEASE_REPO" --tag "v2026.4.28+23" >"$TMPDIR/out" 2>"$TMPDIR/err"; then + fail "expected missing tag to fail" +fi +assert_contains "$(cat "$TMPDIR/err")" "does not exist" + +git -C "$RELEASE_REPO" tag v2026.4.28+23 +echo "change after tag" >"$RELEASE_REPO/after-tag.txt" +git -C "$RELEASE_REPO" add after-tag.txt +git -C "$RELEASE_REPO" commit --quiet -m "move head" +if "$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --repo "$RELEASE_REPO" --tag "v2026.4.28+23" >"$TMPDIR/out" 2>"$TMPDIR/err"; then + fail "expected tag not at HEAD to fail" +fi +assert_contains "$(cat "$TMPDIR/err")" "does not point at HEAD" + +write_pubspec "2026.4.28" +if "$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --repo "$RELEASE_REPO" >"$TMPDIR/out" 2>"$TMPDIR/err"; then + fail "expected missing build number to fail" +fi +assert_contains "$(cat "$TMPDIR/err")" "must include a build number" + +write_pubspec_line 'version: "2026.4.28+23" # release candidate' +output="$("$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --repo "$RELEASE_REPO")" +assert_contains "$output" "full_version=2026.4.28+23" + +write_pubspec_line ' version: "2026.4.28+23" # release candidate' +output="$("$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --repo "$RELEASE_REPO")" +assert_contains "$output" "full_version=2026.4.28+23" + +mkdir "$TMPDIR/outside" +output="$(cd "$TMPDIR/outside" && "$SCRIPT" --pubspec pubspec.yaml --repo "$RELEASE_REPO")" +assert_contains "$output" "full_version=2026.4.28+23" + +rm -f "$TMPDIR/out" +"$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --repo "$RELEASE_REPO" --github-output "$TMPDIR/out" >/dev/null +assert_contains "$(cat "$TMPDIR/out")" "full_version=2026.4.28+23" +assert_contains "$(cat "$TMPDIR/out")" "version_name=2026.4.28" +assert_contains "$(cat "$TMPDIR/out")" "build_number=23" + +if "$SCRIPT" --pubspec >"$TMPDIR/out" 2>"$TMPDIR/err"; then + fail "expected missing --pubspec value to fail" +fi +assert_contains "$(cat "$TMPDIR/err")" "Missing value for --pubspec" + +if "$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --repo --tag "v2026.4.28+23" >"$TMPDIR/out" 2>"$TMPDIR/err"; then + fail "expected missing --repo value to fail" +fi +assert_contains "$(cat "$TMPDIR/err")" "Missing value for --repo" + +if "$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --tag --github-output "$TMPDIR/github-output" >"$TMPDIR/out" 2>"$TMPDIR/err"; then + fail "expected missing --tag value to fail" +fi +assert_contains "$(cat "$TMPDIR/err")" "Missing value for --tag" + +if "$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --github-output >"$TMPDIR/out" 2>"$TMPDIR/err"; then + fail "expected missing --github-output value to fail" +fi +assert_contains "$(cat "$TMPDIR/err")" "Missing value for --github-output" + +echo "validate_release_version_test passed" diff --git a/zapstore.yaml b/zapstore.yaml index abd616d..ddda337 100644 --- a/zapstore.yaml +++ b/zapstore.yaml @@ -1,4 +1,5 @@ repository: https://github.com/marmot-protocol/whitenoise +match: "^whitenoise-[0-9].*-arm64-v8a\\.apk$" description: | **White Noise: The Identity-Free Messenger for Private, Censorship-Resistant Communication**