From 340fbc60a5f4d090bb4950b1d5d5b44e88bcb79c Mon Sep 17 00:00:00 2001 From: VincenzoImp Date: Sat, 29 Aug 2026 14:06:23 +0200 Subject: [PATCH] ci: harden release and platform verification --- .dockerignore | 34 + .flutter-version | 1 + .github/ISSUE_TEMPLATE/bug_report.yml | 4 + .github/ISSUE_TEMPLATE/feature_request.yml | 6 +- .github/dependabot.yml | 5 + .github/workflows/ci.yml | 77 +- .github/workflows/release-please.yml | 13 +- .github/workflows/release.yml | 106 ++- .gitignore | 2 + CONTRIBUTING.md | 7 +- Dockerfile | 16 +- README.md | 18 +- android/app/build.gradle.kts | 24 +- .../gradle/wrapper/gradle-wrapper.properties | 1 + docker-compose.yml | 17 +- .../android/en-US/full_description.txt | 6 +- linux/flutter/generated_plugins.cmake | 1 + test/platform_configuration_test.dart | 768 ++++++++++++++++-- windows/flutter/generated_plugins.cmake | 1 + 19 files changed, 942 insertions(+), 165 deletions(-) create mode 100644 .dockerignore create mode 100644 .flutter-version diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff81693 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Secrets and signing material +.env +.env.* +*.jks +*.keystore +*.p12 +*.pfx +*.pem +*.key +secrets/ +android/key.properties + +# Generated output and dependency metadata +build/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +artifacts/ +docker-output/ +.clean-check-tmp/ + +# Version-control metadata +.git/ +.gitignore + +# Local IDE and machine configuration +.idea/ +.vscode/ +*.iml +android/local.properties + +# Files not needed to build the image +coverage/ +.DS_Store diff --git a/.flutter-version b/.flutter-version new file mode 100644 index 0000000..faf0dcb --- /dev/null +++ b/.flutter-version @@ -0,0 +1 @@ +3.44.0 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4833f3d..599f0a5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -11,7 +11,11 @@ body: to the raw photo. This matters a lot for triage. options: - Android + - iOS - Web + - Linux + - macOS + - Windows - Other (specify below) validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 08b7399..340ba81 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -23,9 +23,9 @@ body: attributes: label: Privacy description: >- - FOSScanner makes no network calls of its own and never persists - scans/PDFs beyond the current session (see the README). Please - confirm your suggestion doesn't require breaking that. + FOSScanner makes no network calls of its own and keeps in-progress + pages in memory (the OS may cache a shared PDF; see the README). + Please confirm your suggestion doesn't require breaking that. options: - label: This doesn't require adding network calls or persistent storage of scanned content required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0700983..f8411ea 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,3 +10,8 @@ updates: directory: "/" schedule: interval: "weekly" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97602a9..259936b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,33 +6,86 @@ on: pull_request: branches: [main, dev] +permissions: + contents: read + jobs: - analyze-and-test: + quality: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: subosito/flutter-action@v2 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: - channel: stable + flutter-version: '3.44.0' - run: flutter pub get + - run: flutter analyze + - run: flutter test - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: pubspec-lock path: pubspec.lock retention-days: 14 - - run: flutter analyze - - run: flutter test - # Tests compile the host (Linux) native assets only. Compile one shipping - # Android ABI as well so Gradle/NDK/native-asset regressions fail before - # a release tag is created. - - run: flutter build apk --debug --target-platform android-arm64 + web: + needs: quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + flutter-version: '3.44.0' + - run: flutter build web + + linux: + needs: quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + flutter-version: '3.44.0' + - run: sudo apt-get update && sudo apt-get install -y ninja-build libgtk-3-dev + - run: flutter build linux - - uses: actions/upload-artifact@v4 + android: + needs: quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: '17' + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + flutter-version: '3.44.0' + - run: flutter build apk --debug --target-platform android-arm64 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: app-debug-arm64 path: build/app/outputs/flutter-apk/app-debug.apk retention-days: 14 + + apple: + needs: quality + runs-on: macos-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + flutter-version: '3.44.0' + - run: flutter build ios --no-codesign + - run: flutter build macos + + windows: + needs: quality + runs-on: windows-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + flutter-version: '3.44.0' + - run: flutter build windows diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 5f053f6..c7330db 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -14,17 +14,12 @@ jobs: contents: write pull-requests: write steps: - # Default GITHUB_TOKEN is read-only for this repo/org with no - # repo-level override (see release.yml) and release-please needs to - # open PRs and push tags, so it needs the same RELEASE_TOKEN PAT. - - uses: googleapis/release-please-action@v4 + # The organization enforces a read-only GITHUB_TOKEN. Keep the + # repository-scoped PAT confined to this publishing action. + - uses: googleapis/release-please-action@8b8fd2cc23b2e18957157a9d923d75aa0c6f6ad5 # v4 with: token: ${{ secrets.RELEASE_TOKEN }} config-file: release-please-config.json manifest-file: .release-please-manifest.json - # Without this, release-please-action defaults to the repo's - # GitHub-configured default branch — which is `dev` (the PR - # target for day-to-day work), not `main` (the only branch - # this workflow actually triggers on and releases from). Pin - # it explicitly so the two can't silently diverge again. + # The repository default branch is dev, while releases come from main. target-branch: main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63f154a..075cabf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,53 +3,107 @@ name: Release on: push: tags: - - 'v*' + # Workflow filters use glob syntax; the shell guard below enforces exact SemVer. + - 'v[0-9]*.[0-9]*.[0-9]*' + +permissions: + contents: read jobs: + preflight: + runs-on: ubuntu-latest + outputs: + validated-sha: ${{ steps.validate.outputs.sha }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Validate tag, version, and provenance + id: validate + shell: bash + run: | + if [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Invalid release tag: $GITHUB_REF_NAME" >&2 + exit 1 + fi + + pubspec_version="$(awk '$1 == "version:" { print $2 }' pubspec.yaml)" + release_version="${pubspec_version%%+*}" + if [[ "$GITHUB_REF_NAME" != "v$release_version" ]]; then + echo "Tag $GITHUB_REF_NAME does not match pubspec.yaml version $pubspec_version" >&2 + exit 1 + fi + + git fetch --no-tags origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" "origin/main"; then + echo "Release tag commit is not an ancestor of origin/main" >&2 + exit 1 + fi + + validated_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" + echo "sha=$validated_sha" >> "$GITHUB_OUTPUT" + + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 + with: + flutter-version: '3.44.0' + + - name: Quality gate + run: | + flutter pub get + flutter analyze + flutter test + build-and-release: + needs: preflight runs-on: ubuntu-latest + environment: release permissions: - contents: write + contents: read + id-token: write + attestations: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.preflight.outputs.validated-sha }} - - uses: actions/setup-java@v4 + - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: - distribution: temurin - java-version: '17' + flutter-version: '3.44.0' - - uses: subosito/flutter-action@v2 + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 with: - channel: stable + distribution: temurin + java-version: '17' - name: Restore release keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} run: | - echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/upload-keystore.jks + test -n "$ANDROID_KEYSTORE_BASE64" || { echo "ANDROID_KEYSTORE_BASE64 is required" >&2; exit 1; } + test -n "$ANDROID_KEYSTORE_PASSWORD" || { echo "ANDROID_KEYSTORE_PASSWORD is required" >&2; exit 1; } + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks cat > android/key.properties < Actions > General > Workflow permissions is locked - # to "Read repository contents and packages permissions" and - # can't be changed at the repo level), so job-level - # `permissions: contents: write` above isn't enough on its own — - # a PAT scoped to this repo is required to actually create the - # release. RELEASE_TOKEN: fine-grained PAT, Contents: Read and - # write, scoped to this repo only. + # The organization enforces a read-only GITHUB_TOKEN, so publishing + # uses a repository-scoped PAT only in this action. GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} diff --git a/.gitignore b/.gitignore index 2428484..c5a8196 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ CLAUDE.md .pub/ /build/ /coverage/ +/docker-output/ +/.clean-check-tmp/ # Symbolication related app.*.symbols diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99f93b2..af4e4eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,9 +54,10 @@ add `Co-Authored-By` trailers for AI coding assistants. ## Reporting bugs / requesting features -Use the issue templates — they ask for the platform (Android/web) and -Flutter version, which is usually the first thing needed to reproduce -anything in this codebase given how much of it is native-only. +Use the issue templates — they ask whether the problem occurs on Android, +iOS, web, Linux, macOS, or Windows, plus the Flutter version. That platform +report is usually the first thing needed to reproduce an issue in this +codebase given how much behavior is platform-specific. ## Security diff --git a/Dockerfile b/Dockerfile index 26c72d3..c1d0986 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,26 @@ -FROM ghcr.io/cirruslabs/flutter:stable +# Flutter 3.44.0; pin the multi-platform image index, not a mutable tag. +FROM ghcr.io/cirruslabs/flutter@sha256:46691e311715845de03a3ba4753a475476936805b29431b1f00f1816981033f8 -# cmake/ninja/build-essential: required to build opencv_dart's native -# (dartcv4) component via Dart's native-assets build hooks during Flutter -# builds. +# Native assets need CMake/Ninja; Linux desktop builds additionally need +# Clang and GTK development headers. The base digest is pinned, while apt +# security package versions intentionally resolve from the archive at build time. RUN apt-get update && apt-get install -y --no-install-recommends \ cmake \ ninja-build \ build-essential \ + clang \ + libgtk-3-dev \ && rm -rf /var/lib/apt/lists/* # Set the working directory WORKDIR /app -# Cache package downloads in the image; the bind-mounted project regenerates -# only its lightweight .dart_tool metadata when a container starts. +# Cache package downloads before copying the rest of the project. COPY pubspec.* ./ RUN flutter pub get +COPY . . + # Safe directory for git RUN git config --global --add safe.directory /app RUN git config --global --add safe.directory /sdks/flutter diff --git a/README.md b/README.md index 1568fec..ad69b70 100644 --- a/README.md +++ b/README.md @@ -88,23 +88,23 @@ flutter run |---|---| | `flutter analyze` | Static analysis / lint | | `flutter test` | Run the test suite | -| `flutter build apk --split-per-abi` | Build signed, per-ABI release APKs | +| `flutter build apk --release --split-per-abi` | Build signed, per-ABI release APKs (requires `android/key.properties`) | | `flutter build web` | Build a release web bundle | ### Running with Docker -`docker-compose.yml` provides two services that build against -`ghcr.io/cirruslabs/flutter:stable`, so you don't need the Flutter/Android -SDKs installed locally. The APK service uses the image's x86_64 Android -SDK/NDK toolchain; Docker Desktop uses emulation automatically on Apple -Silicon, so that build is slower there. Native arm64 Linux Docker engines need -amd64 emulation (for example, binfmt/QEMU) for the APK service: +`docker-compose.yml` provides two services built from the pinned Flutter +3.44.0 container image, so you don't need the Flutter/Android SDKs installed +locally. The APK service uses the image's x86_64 Android SDK/NDK toolchain; +Docker Desktop uses emulation automatically on Apple Silicon, so that build is +slower there. Native arm64 Linux Docker engines need amd64 emulation (for +example, binfmt/QEMU) for the APK service: ```bash -# Web preview, served on http://localhost:8080 +# Web preview, bound to this machine and served on http://localhost:8080 docker compose up flutter-web -# Release Android APKs (one per ABI), output to build/app/outputs/flutter-apk/ +# Debug Android APKs (one per ABI), output to ./docker-output/ docker compose run --rm build-apk ``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 282c1e4..e18b2a2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,8 +8,8 @@ plugins { } // Release signing lives outside version control (android/key.properties, gitignored; -// see android/key.properties.example). Falls back to debug signing when it's absent -// so `flutter run --release` and local/CI analyze-and-test still work without secrets. +// see android/key.properties.example). Debug and release verification tasks do not need +// this file, but release artifact tasks must never fall back to the debug key. val keystorePropertiesFile = rootProject.file("key.properties") val keystoreProperties = Properties() val hasReleaseKeystore = keystorePropertiesFile.exists() @@ -17,6 +17,20 @@ if (hasReleaseKeystore) { keystoreProperties.load(FileInputStream(keystorePropertiesFile)) } +gradle.taskGraph.whenReady { + val releaseArtifactTask = + Regex("^(assemble|bundle|package).*Release(?:Bundle|UniversalApk)?\$") + val requestsReleaseArtifact = allTasks.any { task -> + releaseArtifactTask.matches(task.name) + } + if (requestsReleaseArtifact && !hasReleaseKeystore) { + throw GradleException( + "Release signing is required: copy android/key.properties.example " + + "to android/key.properties and configure a release keystore.", + ) + } +} + android { namespace = "com.fosscanner.app" compileSdk = flutter.compileSdkVersion @@ -50,10 +64,8 @@ android { buildTypes { release { - signingConfig = if (hasReleaseKeystore) { - signingConfigs.getByName("release") - } else { - signingConfigs.getByName("debug") + if (hasReleaseKeystore) { + signingConfig = signingConfigs.getByName("release") } } } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 2d428bf..6775ba6 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,3 +3,4 @@ distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip +distributionSha256Sum=b84e04fa845fecba48551f425957641074fcc00a88a84d2aae5808743b35fc85 diff --git a/docker-compose.yml b/docker-compose.yml index 5b7c1fe..11a5c9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,17 +4,14 @@ services: build: . container_name: fosscanner_web ports: - - "8080:8080" - volumes: - - .:/app - # Ensure the flutter project structure exists, get dependencies, and run the web server - # Uses a real release build (minified, tree-shaken) rather than + - "127.0.0.1:8080:8080" + # Uses the source baked into the image and a real release build (minified, + # tree-shaken) rather than # `flutter run -d web-server`: the debug dev-server ships an unoptimized, # multi-megabyte JS bundle intended for desktop hot-reload, which can be # slow or fail to render at all on a phone's browser. command: > - bash -c "flutter pub get && - flutter build web && + bash -c "flutter build web && cd build/web && python3 -m http.server 8080 --bind 0.0.0.0" @@ -27,7 +24,7 @@ services: platform: linux/amd64 container_name: fosscanner_apk_builder volumes: - - .:/app + - ./docker-output:/app/docker-output command: > - bash -c "flutter pub get && - flutter build apk --split-per-abi" + bash -c "flutter build apk --debug --split-per-abi && + cp build/app/outputs/flutter-apk/app-*-debug.apk /app/docker-output/" diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 9433de6..115837b 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -19,6 +19,8 @@ Features: Privacy: * All image processing and PDF generation happens on-device -* Captured photos and the generated PDF are only ever held in memory or - short-lived temp storage, cleaned up as soon as they're no longer needed +* Imported gallery originals are never modified or deleted. App-owned camera temp files + are removed after the app attempts to copy them into memory, including failed reads +* In-progress pages stay in memory. Sharing a PDF may create a copy in an OS-managed cache + for the receiving app, and the OS decides when that cache is removed * The app makes no network requests of its own diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 886932b..e7eedf5 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_zxing jni ) diff --git a/test/platform_configuration_test.dart b/test/platform_configuration_test.dart index 7d7441d..646aa94 100644 --- a/test/platform_configuration_test.dart +++ b/test/platform_configuration_test.dart @@ -5,19 +5,206 @@ import 'package:flutter_test/flutter_test.dart'; Iterable _activeLines(String source) => source.split('\n').where((line) => !line.trimLeft().startsWith('#')); +String _activeSource(String source) => _activeLines(source).join('\n'); + bool _hasActiveLine(String source, RegExp pattern) => _activeLines(source).any(pattern.hasMatch); -void _expectActiveLineCount(String path, RegExp pattern, int expectedCount) { - final source = File(path).readAsStringSync(); - expect(source, isNot(contains('com.example')), reason: path); +Set _assignmentValues(String source, String key) { + final assignment = RegExp( + '^\\s*${RegExp.escape(key)}\\s*=\\s*([^;]+);?\\s*\$', + ); + return { + for (final line in _activeLines(source)) + if (assignment.firstMatch(line) case final match?) match.group(1)!.trim(), + }; +} + +final class _XcodeBuildConfiguration { + const _XcodeBuildConfiguration(this.name, this.settings); + + final String name; + final String settings; +} + +List<_XcodeBuildConfiguration> _xcodeBuildConfigurations(String source) => + RegExp( + r'isa = XCBuildConfiguration;[\s\S]*?buildSettings = \{([\s\S]*?)\};\s*name = ([^;]+);', + ).allMatches(source).map((match) { + return _XcodeBuildConfiguration( + match.group(2)!.replaceAll('"', '').trim(), + match.group(1)!, + ); + }).toList(); + +void _expectXcodeBundleIds({ + required String path, + required String targetSetting, + required String bundleId, +}) { + final configurations = _xcodeBuildConfigurations( + File(path).readAsStringSync(), + ).where((configuration) => configuration.settings.contains(targetSetting)); expect( - _activeLines(source).where(pattern.hasMatch), - hasLength(expectedCount), - reason: '$path must contain every exact FOSScanner identity', + configurations.map((configuration) => configuration.name).toSet(), + containsAll({'Debug', 'Profile', 'Release'}), + reason: '$path: configurations for $targetSetting', ); + for (final configuration in configurations) { + expect( + _assignmentValues(configuration.settings, 'PRODUCT_BUNDLE_IDENTIFIER'), + equals({bundleId}), + reason: '$path: ${configuration.name} for $targetSetting', + ); + } } +List _yamlListValues(String source, String key) { + final lines = source.split('\n'); + final values = []; + + for (var index = 0; index < lines.length; index++) { + final keyMatch = RegExp( + '^(\\s*)${RegExp.escape(key)}:\\s*\$', + ).firstMatch(lines[index]); + if (keyMatch == null) continue; + + final keyIndent = keyMatch.group(1)!.length; + for (index++; index < lines.length; index++) { + final line = lines[index]; + if (line.trim().isEmpty || line.trimLeft().startsWith('#')) continue; + final indent = line.length - line.trimLeft().length; + if (indent <= keyIndent) { + index--; + break; + } + final item = RegExp( + r'''^\s*-\s*['"]?([^'"#]+?)['"]?\s*(?:#.*)?$''', + ).firstMatch(line); + if (item != null) values.add(item.group(1)!.trim()); + } + } + + return values; +} + +final class _WorkflowStep { + const _WorkflowStep(this.source); + + final String source; + + String? get action { + final match = RegExp( + r'^\s*(?:-\s*)?uses:\s*([^@\s]+)@', + multiLine: true, + ).firstMatch(source); + return match?.group(1); + } + + String? get run { + final lines = source.split('\n'); + for (var index = 0; index < lines.length; index++) { + final match = RegExp( + r'^(\s*)(?:-\s+)?run:\s*(.*)$', + ).firstMatch(lines[index]); + if (match == null) continue; + + final value = match.group(2)!.trim(); + if (value != '|' && value != '>') return value; + + final runIndent = match.group(1)!.length; + var commandIndent = -1; + final command = []; + for (index++; index < lines.length; index++) { + final line = lines[index]; + if (line.trim().isEmpty) { + if (commandIndent >= 0) command.add(''); + continue; + } + final indent = line.length - line.trimLeft().length; + if (indent <= runIndent || + (commandIndent >= 0 && indent < commandIndent)) { + break; + } + commandIndent = commandIndent < 0 ? indent : commandIndent; + command.add(line.substring(commandIndent)); + } + return _activeLines(command.join('\n')).join('\n').trim(); + } + return null; + } +} + +Iterable _workflowRunCommands(String source) => + _workflowSteps(source).map((step) => step.run).whereType(); + +List<_WorkflowStep> _workflowSteps(String source) { + final lines = source.split('\n'); + final steps = <_WorkflowStep>[]; + + for (var index = 0; index < lines.length; index++) { + final start = RegExp( + r'^(\s*)-\s+(?:name|uses|run):', + ).firstMatch(lines[index]); + if (start == null) continue; + + final indent = start.group(1)!.length; + var end = index + 1; + while (end < lines.length) { + final next = RegExp( + r'^(\s*)-\s+(?:name|uses|run):', + ).firstMatch(lines[end]); + if (next != null && next.group(1)!.length == indent) break; + if (lines[end].trim().isNotEmpty) { + final nextIndent = lines[end].length - lines[end].trimLeft().length; + if (nextIndent < indent) break; + } + end++; + } + steps.add(_WorkflowStep(lines.sublist(index, end).join('\n'))); + index = end - 1; + } + + return steps; +} + +final class _WorkflowJob { + const _WorkflowJob(this.name, this.source); + + final String name; + final String source; +} + +Map _workflowJobs(String source) { + final lines = source.split('\n'); + final jobs = {}; + final jobsIndex = lines.indexWhere((line) => line.trim() == 'jobs:'); + if (jobsIndex < 0) return jobs; + + for (var index = jobsIndex + 1; index < lines.length; index++) { + final jobStart = RegExp( + r'^ ([a-zA-Z0-9_-]+):\s*$', + ).firstMatch(lines[index]); + if (jobStart == null) continue; + + final name = jobStart.group(1)!; + var end = index + 1; + while (end < lines.length && + RegExp(r'^ [a-zA-Z0-9_-]+:\s*$').firstMatch(lines[end]) == null) { + end++; + } + jobs[name] = _WorkflowJob(name, lines.sublist(index, end).join('\n')); + index = end - 1; + } + + return jobs; +} + +String _runner(_WorkflowJob job) => RegExp( + r'^\s+runs-on:\s*([^\s#]+)', + multiLine: true, +).firstMatch(job.source)!.group(1)!; + void main() { test('active-line checks ignore commented directives', () { expect( @@ -32,92 +219,185 @@ void main() { test( 'native platforms do not ship with placeholder application identifiers', () { - const iosProject = 'ios/Runner.xcodeproj/project.pbxproj'; - _expectActiveLineCount( - iosProject, - RegExp(r'^\s*PRODUCT_BUNDLE_IDENTIFIER = [^;]+;\s*$'), - 6, + const applicationId = 'com.fosscanner.app'; + const testApplicationId = '$applicationId.RunnerTests'; + + _expectXcodeBundleIds( + path: 'ios/Runner.xcodeproj/project.pbxproj', + targetSetting: 'INFOPLIST_FILE = Runner/Info.plist', + bundleId: applicationId, ); - _expectActiveLineCount( - iosProject, - RegExp(r'^\s*PRODUCT_BUNDLE_IDENTIFIER = com\.fosscanner\.app;\s*$'), - 3, + _expectXcodeBundleIds( + path: 'ios/Runner.xcodeproj/project.pbxproj', + targetSetting: 'TEST_HOST =', + bundleId: testApplicationId, ); - _expectActiveLineCount( - iosProject, - RegExp( - r'^\s*PRODUCT_BUNDLE_IDENTIFIER = ' - r'com\.fosscanner\.app\.RunnerTests;\s*$', - ), - 3, + _expectXcodeBundleIds( + path: 'macos/Runner.xcodeproj/project.pbxproj', + targetSetting: 'TEST_HOST =', + bundleId: testApplicationId, ); - const macosProject = 'macos/Runner.xcodeproj/project.pbxproj'; - _expectActiveLineCount( - macosProject, - RegExp(r'^\s*PRODUCT_BUNDLE_IDENTIFIER = [^;]+;\s*$'), - 3, + final macosConfig = File( + 'macos/Runner/Configs/AppInfo.xcconfig', + ).readAsStringSync(); + expect( + _assignmentValues(macosConfig, 'PRODUCT_BUNDLE_IDENTIFIER'), + equals({applicationId}), ); - _expectActiveLineCount( - macosProject, + + final linuxCmake = File('linux/CMakeLists.txt').readAsStringSync(); + expect( RegExp( - r'^\s*PRODUCT_BUNDLE_IDENTIFIER = ' - r'com\.fosscanner\.app\.RunnerTests;\s*$', - ), - 3, + r'^\s*set\(APPLICATION_ID\s+"([^"]+)"\)\s*$', + multiLine: true, + ).firstMatch(_activeSource(linuxCmake))?.group(1), + applicationId, ); - const macosInfo = 'macos/Runner/Configs/AppInfo.xcconfig'; - _expectActiveLineCount( - macosInfo, - RegExp(r'^\s*PRODUCT_BUNDLE_IDENTIFIER\s*=.*$'), - 1, - ); - _expectActiveLineCount( - macosInfo, - RegExp(r'^\s*PRODUCT_BUNDLE_IDENTIFIER = com\.fosscanner\.app\s*$'), - 1, + final windowsResources = File( + 'windows/runner/Runner.rc', + ).readAsStringSync(); + expect( + RegExp( + r'^\s*VALUE "CompanyName",\s*"([^"]+)"\s*"\\0"\s*$', + multiLine: true, + ).firstMatch(_activeSource(windowsResources))?.group(1), + 'FOSScanner', ); - const linuxCmake = 'linux/CMakeLists.txt'; - _expectActiveLineCount( - linuxCmake, - RegExp(r'^\s*set\(APPLICATION_ID\s+.*\)\s*$'), - 1, - ); - _expectActiveLineCount( - linuxCmake, - RegExp(r'^\s*set\(APPLICATION_ID "com\.fosscanner\.app"\)\s*$'), - 1, - ); + for (final path in [ + 'ios/Runner.xcodeproj/project.pbxproj', + 'macos/Runner.xcodeproj/project.pbxproj', + 'macos/Runner/Configs/AppInfo.xcconfig', + 'linux/CMakeLists.txt', + 'windows/runner/Runner.rc', + ]) { + expect( + File(path).readAsStringSync(), + isNot(contains('com.example')), + reason: path, + ); + } + }, + ); - const windowsResources = 'windows/runner/Runner.rc'; - _expectActiveLineCount( - windowsResources, - RegExp(r'^\s*VALUE "CompanyName",.*$'), - 1, - ); - _expectActiveLineCount( - windowsResources, - RegExp(r'^\s*VALUE "CompanyName", "FOSScanner" "\\0"\s*$'), - 1, + test( + 'workflow actions use approved immutable revisions and Flutter version', + () { + const approvedActions = { + 'actions/checkout': ( + sha: '11d5960a326750d5838078e36cf38b85af677262', + version: 'v4', + ), + 'subosito/flutter-action': ( + sha: '1a449444c387b1966244ae4d4f8c696479add0b2', + version: 'v2', + ), + 'actions/upload-artifact': ( + sha: 'ea165f8d65b6e75b540449e92b4886f43607fa02', + version: 'v4', + ), + 'actions/setup-java': ( + sha: 'cf277c60eb25467037889841efdb72551f06f6c3', + version: 'v4', + ), + 'softprops/action-gh-release': ( + sha: '3bb12739c298aeb8a4eeaf626c5b8d85266b0e65', + version: 'v2', + ), + 'actions/attest-build-provenance': ( + sha: '43d14bc2b83dec42d39ecae14e916627a18bb661', + version: 'v3', + ), + 'googleapis/release-please-action': ( + sha: '8b8fd2cc23b2e18957157a9d923d75aa0c6f6ad5', + version: 'v4', + ), + }; + final actionPattern = RegExp( + r'^\s*(?:-\s+)?uses:\s*([^@\s]+)@([^\s#]+)\s+#\s*(v\d+)\s*$', + multiLine: true, ); + + for (final workflow + in Directory('.github/workflows').listSync().whereType().where( + (file) => file.path.endsWith('.yml'), + )) { + final source = workflow.readAsStringSync(); + final usesLines = RegExp( + r'^\s*(?:-\s+)?uses:', + multiLine: true, + ).allMatches(_activeSource(source)).length; + final actions = actionPattern + .allMatches(_activeSource(source)) + .toList(); + expect( + actions, + hasLength(usesLines), + reason: '${workflow.path} action pin/comment', + ); + + for (final action in actions) { + final name = action.group(1)!; + final approved = approvedActions[name]; + expect(approved, isNotNull, reason: '${workflow.path}: $name'); + expect( + action.group(2), + approved!.sha, + reason: '${workflow.path}: $name', + ); + expect( + action.group(3), + approved.version, + reason: '${workflow.path}: $name', + ); + } + + for (final step in _workflowSteps( + source, + ).where((step) => step.action == 'subosito/flutter-action')) { + expect( + step.source, + matches( + RegExp( + r'''^\s+flutter-version:\s*['"]?3\.44\.0['"]?\s*$''', + multiLine: true, + ), + ), + reason: workflow.path, + ); + } + + for (final job in _workflowJobs(source).values.where( + (job) => _workflowRunCommands( + job.source, + ).any((command) => command.contains('flutter ')), + )) { + expect( + _workflowSteps(job.source).map((step) => step.action), + contains('subosito/flutter-action'), + reason: '${workflow.path}: ${job.name} must install pinned Flutter', + ); + } + } + + expect(File('.flutter-version').readAsStringSync().trim(), '3.44.0'); }, ); - test('Docker services preserve checked-in platform projects', () { - final compose = File('docker-compose.yml').readAsStringSync(); - - expect(compose, isNot(contains('flutter create .'))); - expect(compose, isNot(contains("version: '3.8'"))); + test('Docker context is private and services use narrow exposure', () { + final dockerfile = File('Dockerfile').readAsStringSync(); expect( - _hasActiveLine(compose, RegExp(r'^\s*platform:\s*linux/amd64\s*$')), + _hasActiveLine( + dockerfile, + RegExp( + r'^FROM ghcr\.io/cirruslabs/flutter@sha256:' + r'46691e311715845de03a3ba4753a475476936805b29431b1f00f1816981033f8\s*$', + ), + ), isTrue, ); - }); - - test('Docker native builds install Ninja and cache pub dependencies', () { - final dockerfile = File('Dockerfile').readAsStringSync(); expect( _hasActiveLine(dockerfile, RegExp(r'^\s*ninja-build\s*\\\s*$')), isTrue, @@ -126,18 +406,348 @@ void main() { _hasActiveLine(dockerfile, RegExp(r'^\s*RUN flutter pub get\s*$')), isTrue, ); + + final dockerignore = File('.dockerignore'); + expect(dockerignore.existsSync(), isTrue); + if (!dockerignore.existsSync()) return; + final ignored = _activeLines(dockerignore.readAsStringSync()) + .map((line) => line.trim().replaceFirst(RegExp(r'/$'), '')) + .where((line) => line.isNotEmpty) + .toSet(); + expect( + ignored, + containsAll({ + '.env', + '.env.*', + '*.jks', + '*.keystore', + 'android/key.properties', + 'build', + '.dart_tool', + '.git', + '.idea', + '.vscode', + '*.iml', + 'android/local.properties', + 'docker-output', + '.clean-check-tmp', + }), + ); + expect( + ignored.where((pattern) => pattern.startsWith('!')), + isEmpty, + reason: 'Docker ignore exclusions must not be re-included later', + ); + final gitIgnored = _activeLines(File('.gitignore').readAsStringSync()) + .map((line) => line.trim().replaceAll(RegExp(r'^/|/$'), '')) + .toSet(); + expect(gitIgnored, containsAll({'docker-output', '.clean-check-tmp'})); + + final compose = File('docker-compose.yml').readAsStringSync(); + final activeCompose = _activeSource(compose); + expect(compose, isNot(contains('flutter create .'))); + final publishedPorts = _yamlListValues(compose, 'ports'); + expect(publishedPorts, contains('127.0.0.1:8080:8080')); + expect( + publishedPorts, + everyElement(startsWith('127.0.0.1:')), + reason: 'Every Docker host port must bind only to loopback', + ); + final volumes = _yamlListValues(compose, 'volumes'); + expect(volumes, equals(['./docker-output:/app/docker-output'])); + expect( + _hasActiveLine(compose, RegExp(r'^\s*platform:\s*linux/amd64\s*$')), + isTrue, + ); + final apkCommands = RegExp( + r'flutter build apk\b[^\n"]*', + ).allMatches(activeCompose).map((match) => match.group(0)!); + expect(apkCommands, isNotEmpty); + expect(apkCommands, everyElement(contains('--debug'))); + expect( + activeCompose, + contains('flutter build apk --debug --split-per-abi'), + ); + expect(activeCompose, contains('/app/docker-output/')); + expect(File('README.md').readAsStringSync(), contains('./docker-output/')); + expect( + _activeSource(dockerfile), + matches(RegExp(r'^COPY \. \.$', multiLine: true)), + ); }); - test('CI compiles a shipping Android ABI', () { + test('Android release signing cannot fall back to a debug key', () { + final gradle = File('android/app/build.gradle.kts').readAsStringSync(); + final active = _activeSource(gradle); + expect(active, isNot(contains('signingConfigs.getByName("debug")'))); + expect(active, contains('gradle.taskGraph.whenReady')); expect( - _hasActiveLine( - File('.github/workflows/ci.yml').readAsStringSync(), + active, + contains( + r'Regex("^(assemble|bundle|package).*Release(?:Bundle|UniversalApk)?\$")', + ), + ); + expect( + active, + matches( RegExp( - r'^\s*-\s+run:\s+flutter build apk --debug ' - r'--target-platform android-arm64\s*$', + r'requestsReleaseArtifact\s*&&\s*!hasReleaseKeystore[\s\S]*throw GradleException' + r'[\s\S]*Release signing is required', ), ), + ); + + final releaseArtifactTask = RegExp( + r'^(assemble|bundle|package).*Release(?:Bundle|UniversalApk)?$', + ); + for (final task in [ + 'assembleRelease', + 'assembleFossRelease', + 'bundleRelease', + 'packageReleaseBundle', + 'packageReleaseUniversalApk', + ]) { + expect(releaseArtifactTask.hasMatch(task), isTrue, reason: task); + } + for (final task in [ + 'lintRelease', + 'testReleaseUnitTest', + 'packageReleaseResources', + 'assembleReleaseUnitTest', + 'assembleDebug', + ]) { + expect(releaseArtifactTask.hasMatch(task), isFalse, reason: task); + } + }); + + test('Gradle distribution has the approved checksum', () { + final properties = _activeSource( + File( + 'android/gradle/wrapper/gradle-wrapper.properties', + ).readAsStringSync(), + ); + final values = { + for (final line in properties.split('\n')) + if (line.contains('=')) + line.substring(0, line.indexOf('=')): line.substring( + line.indexOf('=') + 1, + ), + }; + expect( + values['distributionUrl'], + r'https\://services.gradle.org/distributions/gradle-9.1.0-all.zip', + ); + expect( + values['distributionSha256Sum'], + 'b84e04fa845fecba48551f425957641074fcc00a88a84d2aae5808743b35fc85', + ); + }); + + test('release preflight validates the tag before the release environment', () { + final release = File('.github/workflows/release.yml').readAsStringSync(); + expect( + _yamlListValues(release, 'tags'), + equals(['v[0-9]*.[0-9]*.[0-9]*']), + ); + + final jobs = _workflowJobs(release); + expect(jobs.keys, equals(['preflight', 'build-and-release'])); + final preflight = jobs['preflight']!; + final build = jobs['build-and-release']!; + expect(preflight.source, isNot(contains('environment:'))); + expect(preflight.source, isNot(contains(r'${{ secrets.'))); + expect( + build.source, + matches(RegExp(r'^\s+needs:\s*preflight\s*$', multiLine: true)), + ); + expect( + build.source, + matches(RegExp(r'^\s+environment:\s*release\s*$', multiLine: true)), + ); + + final preflightCommands = _workflowRunCommands( + preflight.source, + ).join('\n'); + const exactSemverPattern = + r'^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'; + expect(preflightCommands, contains(exactSemverPattern)); + final exactSemver = RegExp(exactSemverPattern); + for (final tag in ['v0.0.0', 'v1.2.2', 'v10.20.30']) { + expect(exactSemver.hasMatch(tag), isTrue, reason: tag); + } + for (final tag in ['v01.2.2', 'v1.2', 'v1.2.2-rc.1', 'v1.2.2+5']) { + expect(exactSemver.hasMatch(tag), isFalse, reason: tag); + } + expect( + preflightCommands, + contains(r'git merge-base --is-ancestor "$GITHUB_SHA" "origin/main"'), + ); + expect(preflightCommands, contains('pubspec.yaml')); + expect(preflightCommands, contains(r'${pubspec_version%%+*}')); + expect( + preflightCommands, + contains(r'"$GITHUB_REF_NAME" != "v$release_version"'), + ); + for (final command in [ + 'flutter pub get', + 'flutter analyze', + 'flutter test', + ]) { + expect(preflightCommands, contains(command), reason: command); + } + + final preflightCheckout = _workflowSteps( + preflight.source, + ).singleWhere((step) => step.action == 'actions/checkout'); + expect( + preflightCheckout.source, + matches(RegExp(r'^\s+fetch-depth:\s*0\s*$', multiLine: true)), + ); + final buildCheckout = _workflowSteps( + build.source, + ).singleWhere((step) => step.action == 'actions/checkout'); + expect( + buildCheckout.source, + contains(r'ref: ${{ needs.preflight.outputs.validated-sha }}'), + ); + }); + + test('release attests every APK and fails if publishing matches nothing', () { + final release = File('.github/workflows/release.yml').readAsStringSync(); + final jobs = _workflowJobs(release); + final preflight = jobs['preflight']!; + final build = jobs['build-and-release']!; + expect(preflight.source, isNot(contains('id-token:'))); + expect(preflight.source, isNot(contains('attestations:'))); + for (final permission in [ + 'contents: read', + 'id-token: write', + 'attestations: write', + ]) { + expect(build.source, contains(permission), reason: permission); + } + + final steps = _workflowSteps(build.source); + final buildIndex = steps.indexWhere( + (step) => step.run?.contains('flutter build apk --release') ?? false, + ); + final attestIndex = steps.indexWhere( + (step) => step.action == 'actions/attest-build-provenance', + ); + expect(buildIndex, greaterThanOrEqualTo(0)); + expect(attestIndex, greaterThan(buildIndex)); + expect( + steps[attestIndex].source, + contains('subject-path: build/app/outputs/flutter-apk/app-*-release.apk'), + ); + + final publish = steps.singleWhere( + (step) => step.action == 'softprops/action-gh-release', + ); + expect(publish.source, contains('fail_on_unmatched_files: true')); + }); + + test('release PAT is scoped to publishing action steps', () { + final releaseWorkflows = [ + File('.github/workflows/release.yml'), + File('.github/workflows/release-please.yml'), + ]; + + for (final workflow in releaseWorkflows) { + final active = _activeSource(workflow.readAsStringSync()); + final patSteps = _workflowSteps( + active, + ).where((step) => step.source.contains('secrets.RELEASE_TOKEN')).toList(); + expect(patSteps, hasLength(1), reason: workflow.path); + expect( + patSteps.single.action, + anyOf( + 'softprops/action-gh-release', + 'googleapis/release-please-action', + ), + reason: workflow.path, + ); + } + }); + + test('CI builds every supported platform and runs quality once', () { + final source = File('.github/workflows/ci.yml').readAsStringSync(); + final jobs = _workflowJobs(source); + + _WorkflowJob jobWith(String command) => jobs.values.singleWhere( + (job) => _workflowRunCommands( + job.source, + ).any((run) => run.contains(command)), + orElse: () => throw TestFailure('No CI job runs `$command`'), + ); + + expect(_runner(jobWith('flutter analyze')), 'ubuntu-latest'); + expect(_runner(jobWith('flutter build web')), 'ubuntu-latest'); + expect(_runner(jobWith('flutter build linux')), 'ubuntu-latest'); + expect(_runner(jobWith('flutter build apk --debug')), 'ubuntu-latest'); + expect(_runner(jobWith('flutter build ios --no-codesign')), 'macos-latest'); + expect(_runner(jobWith('flutter build macos')), 'macos-latest'); + expect(_runner(jobWith('flutter build windows')), 'windows-latest'); + + final commands = _workflowRunCommands(source).toList(); + expect(commands.where((run) => run.contains('flutter test')), hasLength(1)); + expect( + commands.where((run) => run.contains('flutter analyze')), + hasLength(1), + ); + }); + + test('Dependabot covers Docker dependencies', () { + final source = File('.github/dependabot.yml').readAsStringSync(); + expect( + RegExp( + r'''^\s*-\s+package-ecosystem:\s*['"]docker['"]\s*$''', + multiLine: true, + ).hasMatch(_activeSource(source)), isTrue, ); }); + + test('public platform and privacy wording matches supported behavior', () { + final contributing = File( + 'CONTRIBUTING.md', + ).readAsStringSync().toLowerCase(); + for (final platform in [ + 'android', + 'ios', + 'web', + 'linux', + 'macos', + 'windows', + ]) { + expect( + contributing, + contains(platform), + reason: 'CONTRIBUTING.md: $platform', + ); + } + + final issueTemplate = File( + '.github/ISSUE_TEMPLATE/bug_report.yml', + ).readAsStringSync(); + final platforms = _yamlListValues(issueTemplate, 'options').toSet(); + expect( + platforms, + containsAll({'Android', 'iOS', 'Web', 'Linux', 'macOS', 'Windows'}), + ); + + final fastlane = File( + 'fastlane/metadata/android/en-US/full_description.txt', + ).readAsStringSync(); + expect( + fastlane, + matches(RegExp(r'OS-managed\s+cache', caseSensitive: false)), + ); + expect( + fastlane, + matches( + RegExp(r'app-owned\s+camera\s+temp\s+files', caseSensitive: false), + ), + ); + }); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4351e65..14051e4 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_zxing jni )