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
This commit is contained in:
JeffG
2026-04-29 22:34:19 +02:00
committed by GitHub
parent de9ea70804
commit b100fc1ec5
10 changed files with 1071 additions and 0 deletions
+9
View File
@@ -78,6 +78,15 @@ opencode.json
.opencode/ .opencode/
.cursor/ .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 # Flutter DevTools
devtools_options.yaml devtools_options.yaml
# Widgetbook # Widgetbook
+3
View File
@@ -0,0 +1,3 @@
source 'https://rubygems.org'
gem 'fastlane', '~> 2.233'
+238
View File
@@ -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
+212
View File
@@ -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<version>+<build>/<flavor>/`
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.
+202
View File
@@ -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
+72
View File
@@ -186,6 +186,10 @@ validate-locales-keys:
@echo "🔍 Validating l10n keys..." @echo "🔍 Validating l10n keys..."
./scripts/validate-locales-keys.sh ./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 # CLEANING
# ============================================================================== # ==============================================================================
@@ -278,6 +282,74 @@ build-release-android:
build-release-ios: build-release-ios:
./scripts/build_release.sh --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 # BUILDING - iOS
# ============================================================================== # ==============================================================================
+148
View File
@@ -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
+64
View File
@@ -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"
+122
View File
@@ -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" <<EOF
name: whitenoise
version: $version
EOF
}
write_pubspec_line() {
local version_line="$1"
cat >"$RELEASE_REPO/pubspec.yaml" <<EOF
name: whitenoise
$version_line
EOF
}
mkdir "$RELEASE_REPO"
git -C "$RELEASE_REPO" init --quiet
git -C "$RELEASE_REPO" config user.email "release-test@example.com"
git -C "$RELEASE_REPO" config user.name "Release Test"
write_pubspec "2026.4.28+23"
git -C "$RELEASE_REPO" add pubspec.yaml
git -C "$RELEASE_REPO" commit --quiet -m "prepare release"
git -C "$RELEASE_REPO" tag v2026.4.28+23
output="$("$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --repo "$RELEASE_REPO" --tag "v2026.4.28+23")"
assert_contains "$output" "full_version=2026.4.28+23"
assert_contains "$output" "version_name=2026.4.28"
assert_contains "$output" "build_number=23"
assert_contains "$output" "tag=v2026.4.28+23"
assert_contains "$output" "tag_commit=$(git -C "$RELEASE_REPO" rev-parse HEAD)"
if "$SCRIPT" --pubspec "$RELEASE_REPO/pubspec.yaml" --repo "$RELEASE_REPO" --tag "v2026.4.28+24" >"$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"
+1
View File
@@ -1,4 +1,5 @@
repository: https://github.com/marmot-protocol/whitenoise repository: https://github.com/marmot-protocol/whitenoise
match: "^whitenoise-[0-9].*-arm64-v8a\\.apk$"
description: | description: |
**White Noise: The Identity-Free Messenger for Private, Censorship-Resistant Communication** **White Noise: The Identity-Free Messenger for Private, Censorship-Resistant Communication**