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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |