Skip to content

feat: the paywall follows the design on every arm — the five pitch rows counted from content, the legal row as fine print, and the two experiment arms drawn #788

feat: the paywall follows the design on every arm — the five pitch rows counted from content, the legal row as fine print, and the two experiment arms drawn

feat: the paywall follows the design on every arm — the five pitch rows counted from content, the legal row as fine print, and the two experiment arms drawn #788

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
# Run by hand on any branch — the only way to exercise the smoke job
# before a change to it reaches main.
workflow_dispatch:
env:
FLUTTER_VERSION: "3.44.1"
jobs:
# Pull requests only: the check compares a branch against its base, and by the
# time a commit is on main it has already been through it. Skipped entirely
# when the PR carries the `no-changelog` label — the escape hatch for
# refactors, formatting passes and regenerated output, which the changelog
# deliberately does not record.
changelog:
name: changelog
runs-on: ubuntu-latest
if: >
github.event_name == 'pull_request' &&
!contains(github.event.pull_request.labels.*.name, 'no-changelog')
steps:
# Full history: the check diffs against the merge base, which a shallow
# clone does not contain.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Require a changelog entry for product changes
run: tool/check_changelog.sh "${{ github.event.pull_request.base.sha }}"
# Pull requests only: every Dart file the branch touches must be clean of
# over-long comment blocks. Older overruns drain as their files are touched,
# so there is no sweep and no allow-list to maintain.
comments:
name: comments
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Plain Dart is enough: the checker imports nothing outside dart:core.
- uses: dart-lang/setup-dart@v1
- name: Comment blocks in changed files stay under the cap
run: dart tool/check_comments.dart --changed "${{ github.event.pull_request.base.sha }}"
format:
name: format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
# Required for formatting, not just for analysis: `dart format` picks its
# style from the package's language version, which it can only read via
# .dart_tool/package_config.json. Without this step it falls back to the
# newest language version and reformats files that are correctly
# formatted at the pubspec's 3.8 floor — so the job disagreed with every
# local run and with the other two jobs, both of which do resolve.
- name: Install dependencies
run: flutter pub get
# Scoped to source dirs — `dart format .` would descend into build/
# (Firebase plugins copy example apps there with broken includes).
- name: Check formatting
run: dart format --output=none --set-exit-if-changed lib test integration_test tool
analyze-test:
name: analyze & test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
# extract_content_test.dart shells out to `node`. The runner ships a Node
# already, but pinning it makes the dependency explicit rather than an
# accident of the image — and the script takes no npm packages, so there
# is nothing to install.
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
# dart_code_linter's per-function metrics (source-lines-of-code,
# cyclomatic-complexity, nesting, parameter/method counts) aren't surfaced
# by `flutter analyze` — run the CLI gate so metric breaches fail CI.
# no-magic-number is a plugin rule, already enforced by Analyze above.
- name: Metrics (dart_code_linter)
run: >
dart run dart_code_linter:metrics analyze lib
--set-exit-on-violation-level=warning
# Runs every *_test.dart under test/ recursively, including the
# Drift schema smoke/UNIQUE tests in test/database/. Generated
# helpers are committed, so no codegen step is required here.
- name: Test
run: flutter test
ios-build:
name: iOS build
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
# No CocoaPods (project migrated to Swift Package Manager) and no
# GoogleService-Info.plist needed — the build succeeds with Firebase
# gated off (kUseFirebase == false).
- name: Build iOS (no code signing)
run: flutter build ios --release --no-codesign
- name: Verify build artifact
run: ls -la build/ios/iphoneos/Runner.app
# The privacy manifest is wired into the Runner target by hand in
# project.pbxproj (#166). Nothing else notices if that entry is dropped in
# a merge — the app still builds and still boots, and the rejection only
# arrives from App Store Connect. So assert the file actually lands in the
# bundle, and still declares the SQLite file-timestamp reason.
- name: Verify privacy manifest is in the bundle
run: |
MANIFEST=build/ios/iphoneos/Runner.app/PrivacyInfo.xcprivacy
test -f "$MANIFEST" || {
echo "::error::$MANIFEST is missing — the Runner target lost it from Copy Bundle Resources."
exit 1
}
plutil -lint "$MANIFEST"
plutil -extract NSPrivacyAccessedAPITypes.0.NSPrivacyAccessedAPIType raw "$MANIFEST" \
| grep -qx NSPrivacyAccessedAPICategoryFileTimestamp || {
echo "::error::The file-timestamp reason is gone; SQLite still needs it."
exit 1
}
# Same hazard as the manifest above, and the same shape of fix: the
# associated-domains entitlement is wired into the Runner target by hand
# (#171), the app builds and boots without it, and the only symptom is
# that every shared link opens Safari instead. This build is
# --no-codesign, so the entitlement never reaches the bundle — assert the
# wiring in the project file instead, which is the part a merge drops.
- name: Verify the associated-domains entitlement is still wired
run: |
ENTITLEMENTS=ios/Runner/Runner.entitlements
plutil -lint "$ENTITLEMENTS"
# PlistBuddy, not `plutil -extract`: plutil reads dots in a key path
# as separators, and this key name is all dots.
/usr/libexec/PlistBuddy -c 'Print :com.apple.developer.associated-domains:0' \
"$ENTITLEMENTS" | grep -qx 'applinks:brewpath.maximsan.dev' || {
echo "::error::$ENTITLEMENTS no longer claims brewpath.maximsan.dev."
exit 1
}
test "$(grep -c 'CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;' \
ios/Runner.xcodeproj/project.pbxproj)" = 3 || {
echo "::error::Expected all three Runner configs to name the entitlements file."
exit 1
}
# Push to main only. The suite needs a booted simulator, which is macOS-only
# and costs a runner minute or two to start — too much to pay on every PR
# push, and unnecessary, because what it guards against is *rot* rather than
# a specific regression.
#
# It is the only job that runs the app rather than compiling it. Everything
# under `flutter test` uses in-memory Drift, a cleared asset bundle and a
# seeded onboarding flag, so a migration that fails on a real database, an
# asset the pubspec does not bundle, and an unregistered plugin are all
# invisible to it. `iOS build` proves the app links, never that it boots.
#
# This job exists because the suite spent months failing while every PR
# stayed green: nothing ran it, so nobody knew (#187).
smoke:
name: smoke (simulator)
runs-on: macos-latest
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
# A backstop so a wedged runner cannot burn an hour, not a bound on any
# one step. Budget, all measured on this runner: pub ~1 min; a boot of 2
# to 9 min, bounded at 12; the Flutter build 4 to 10 min; Xcode's own
# build and the tests after it.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
# Whichever iPhone the runner image ships — pinning a model would break
# every time GitHub rolls the image forward. tool/ci/boot_simulator.sh
# erases it, boots it, and waits until it can take an app launch; the
# image's preinstalled device migrates its data on first boot for
# minutes, and an app launched before that is refused.
- name: Boot an iPhone simulator
run: |
UDID=$(xcrun simctl list devices available --json | python3 -c "
import json, sys
runtimes = json.load(sys.stdin)['devices']
for runtime, devices in runtimes.items():
if 'iOS' not in runtime:
continue
for device in devices:
if device.get('isAvailable') and device['name'].startswith('iPhone'):
print(device['udid'])
sys.exit(0)
sys.exit('no available iPhone simulator on this runner')
")
echo "Booting $UDID"
tool/ci/boot_simulator.sh "$UDID"
echo "SIMULATOR_UDID=$UDID" >> "$GITHUB_ENV"
# Build the app with the smoke test as its entry point. `flutter build`
# rather than `xcodebuild` alone, because the Flutter tool is what raises
# the generated plugin package's platform floor to the project's 16.0;
# left at the package's default 13.0, the Firebase packages refuse to
# build (the "minimum platform version" error tool/reset_ios_spm.sh
# exists for).
- name: Build the smoke test app
run: flutter build ios --simulator --debug -t integration_test/smoke_test.dart
# Xcode launches the app and reports every Dart test as an XCTest result
# (ios/RunnerTests/RunnerTests.m). Not `flutter test integration_test`:
# that launches the app itself and then waits for "The Dart VM service
# is listening on" to arrive on a `log stream` it starts *after* the
# launch. A live stream shows nothing from before it attached, so when
# the app prints first — which this runner's slower spawn allowed on
# half of the pushes to main — the tool waits forever. Reproduced on
# 2026-09-04 by launching the app and attaching the stream three
# seconds later: the line was in the simulator's log, never in the
# stream.
- name: Smoke test
run: |
set -o pipefail
# No parallel testing: with it on, Xcode clones the simulator and
# runs on the clone, which has to boot all over again — the wait
# the boot step above already paid for.
xcodebuild test \
-workspace ios/Runner.xcworkspace -scheme Runner -configuration Debug \
-destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
-parallel-testing-enabled NO \
-derivedDataPath build/ios_integ \
-resultBundlePath smoke.xcresult \
2>&1 | tee smoke-xcodebuild.log \
| grep -E 'Test Case|Test Suite|Executed|error:|\*\* TEST'
# The full log and Xcode's result bundle outlive the step whatever ends
# it — the job cap, a cancel — so a failure can be read afterwards
# instead of reconstructed from silence.
- name: Keep the smoke logs
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-logs
path: |
smoke-xcodebuild.log
smoke.xcresult
if-no-files-found: ignore
retention-days: 14