diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..6395688 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,3 @@ +creation_rules: + - path_regex: secrets/appsok\.ci\.sops\.json$ + age: "age1l4fylw9cvw49urz84pa02gjupnpnpz8qp6k0t7225wt8lukkg3cs6qflph" diff --git a/README.md b/README.md index e243c84..cc06b4b 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,4 @@ flutter run -d macos - Jenkins 호출에는 macOS network client entitlement가 필요합니다. - ADB 실행과 USB device 접근은 배포 방식에 따라 sandbox 정책 검토가 필요합니다. - Jenkins token은 앱 번들에 넣지 않고 macOS Keychain에 사용자별로 저장하는 방향을 기본값으로 둡니다. +- Developer ID signing, notarization, stapling을 포함한 최종 ZIP 생성 절차는 [macOS 인증 빌드](docs/macos-certified-build.md)를 기준으로 합니다. diff --git a/agent-roadmap/phase/workflow-integration/PHASE.md b/agent-roadmap/phase/workflow-integration/PHASE.md index 701dcdf..af3c8d0 100644 --- a/agent-roadmap/phase/workflow-integration/PHASE.md +++ b/agent-roadmap/phase/workflow-integration/PHASE.md @@ -14,7 +14,10 @@ MVP 설치 흐름을 Teams/Jenkins 알림과 연결하고, 반복 설치와 실 완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. 스케치 Milestone은 아직 구현 가능한 계획이 아니므로 계획 Milestone보다 아래에 둔다. -- [진행중] Teams deep link 설치 진입 +- [검토중] macOS 빌드 CI와 인증 빌드 + - 경로: `agent-roadmap/phase/workflow-integration/milestones/macos-build-ci.md` + - 요약: Teams deep link와 설치 이력 작업 전에 원격 Mac build/CI baseline과 Developer ID notarized 인증 빌드 절차를 확정한다. +- [계획] Teams deep link 설치 진입 - 경로: `agent-roadmap/phase/workflow-integration/milestones/teams-deep-link.md` - 요약: Teams/Jenkins 메시지의 `appsok://` 링크로 특정 job/build/artifact 설치 화면에 바로 진입한다. - [계획] 설치 세션 이력과 오류 리포트 diff --git a/agent-roadmap/phase/workflow-integration/milestones/macos-build-ci.md b/agent-roadmap/phase/workflow-integration/milestones/macos-build-ci.md new file mode 100644 index 0000000..4bfb717 --- /dev/null +++ b/agent-roadmap/phase/workflow-integration/milestones/macos-build-ci.md @@ -0,0 +1,116 @@ +# Milestone: macOS 빌드 CI와 인증 빌드 + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/workflow-integration/PHASE.md` + +## 목표 + +Teams deep link와 설치 이력 작업으로 들어가기 전에, 원격 Mac runner에서 AppSok macOS 빌드가 재현 가능하게 생성되고 최종 배포 후보가 Developer ID signing, notarization, stapling까지 통과하는지 확인한다. 생성된 인증 빌드는 다음 업무 흐름 Milestone의 검증 출발점으로 재사용할 수 있어야 한다. + +## 상태 + +[검토중] + +## 승격 조건 + +- 없음 + +## 구현 잠금 + +- 상태: 해제 +- 결정 필요: 없음 + +## 범위 + +- remote Mac runner의 Flutter/Xcode/codesigning readiness 확인 +- `flutter analyze`, `flutter test`, `flutter build macos`를 CI 후보 명령으로 정리 +- macOS release `.app` bundle 생성 여부 확인 +- Developer ID signing, notarization, stapling, Gatekeeper assessment 확인 +- MDM 배포 정책과 조직 배포 자동화는 후속 배포 Milestone과 분리 + +## 기능 + +### Epic: [baseline] 원격 Mac 빌드 baseline + +원격 Mac runner에서 현재 checkout을 실제로 빌드할 수 있는지 확인한다. + +- [x] [remote-env] remote checkout, Flutter, Xcode, codesigning identity 상태를 확인한다. +- [x] [analyze-test] remote runner에서 `flutter analyze`와 `flutter test`를 통과시킨다. +- [x] [macos-build] remote runner에서 `flutter build macos`로 `Release/AppSok.app`을 생성한다. +- [x] [bundle-check] 빌드 산출물의 앱 이름, bundle id, codesign 상태를 확인한다. + +### Epic: [ci] CI 게이트 정의 + +반복 가능한 CI 검증으로 만들기 위해 명령, runner, 산출물, 실패 기준을 문서화한다. + +- [x] [ci-commands] CI에서 실행할 명령 순서와 기대 출력을 정리한다. +- [x] [runner-contract] remote Mac runner 또는 self-hosted runner 필수 조건을 정리한다. +- [x] [artifact-output] `AppSok.app` 산출물 경로, 보존 방식, build evidence 위치를 정한다. +- [x] [signing-boundary] adhoc build, Developer ID signing, notarization의 책임 경계를 기록한다. + +### Epic: [certified] 최종 인증 빌드 + +최종 배포 후보를 macOS Gatekeeper 기준으로 검증 가능한 상태로 만든다. + +- [x] [keychain-access] 비대화식 CI 세션에서 Developer ID private key 접근이 가능한지 확인한다. +- [x] [developer-id-sign] `AppSok.app`과 nested framework/runtime 파일을 Developer ID로 서명한다. +- [x] [notary-submit] `notarytool submit --wait`로 Apple notarization을 통과시킨다. +- [x] [staple] notarization ticket을 산출물에 staple한다. +- [x] [gatekeeper] `spctl --assess --verbose`로 배포 후보 실행 가능성을 확인한다. +- [x] [certified-artifact] 인증된 산출물 경로와 checksum을 기록한다. + +## 완료 리뷰 + +- 상태: 요청됨 +- 요청일: 2026-06-14 +- 완료 근거: + - 원격 Mac runner에서 Flutter/Xcode/codesigning readiness, `flutter analyze`, `flutter test`, `flutter build macos`를 통과했다. + - Developer ID signing, Apple notarization, stapling, Gatekeeper assessment, 최종 ZIP checksum 기록을 완료했다. + - SOPS secret setup과 인증 빌드 반복 실행 절차를 스크립트, 문서, 테스트로 남겼다. +- 리뷰 필요: + - [ ] 사용자가 완료 결과를 확인했다 + - [ ] archive 이동을 승인했다 +- 리뷰 코멘트: 전체화면 전용 검증은 제품 목표가 아니므로 완료 조건에서 제외한다. 일반 창 기반 업무 흐름 확인은 각 후속 기능 Milestone의 smoke에서 필요할 때 다룬다. + +## 범위 제외 + +- Teams deep link 구현 +- 전체화면 전용 UX 또는 전체화면 모드 검증 +- 실제 Jenkins credential 또는 private endpoint 원문 기록 +- 조직 배포 채널과 MDM 자동화 정책 확정 + +## 작업 컨텍스트 + +- 관련 경로: `macos/`, `pubspec.yaml`, CI 설정 후보 파일 +- 관련 문서: `docs/macos-certified-build.md`, `agent-test/local/macos-remote-build-smoke.md` +- 관련 테스트: `test/certified_macos_scripts_test.dart` +- 표준선(선택): CI baseline은 `flutter pub get`, `flutter analyze`, `flutter test`, `flutter build macos` 순서로 둔다. +- 표준선(선택): 인증서 원문, private key, password, Apple account secret은 채팅/roadmap/tracked 파일에 기록하지 않고 runner Keychain 또는 CI secret으로만 다룬다. +- 표준선(선택): adhoc build, Developer ID signing, notarization은 서로 다른 검증 단계로 기록하고, 인증 빌드는 `scripts/build-certified-macos.sh`로 반복한다. +- 검증 메모: + - 2026-06-14 remote checkout: `main...origin/main`, clean. + - `flutter --version --machine`: Flutter `3.44.0`, Dart `3.12.0`. + - `xcodebuild -version`: Xcode `26.0.1`, Build `17A400`. + - `security find-identity -v -p codesigning`: valid identity 3개 확인. + - `flutter pub get` PASS. + - `flutter analyze` PASS: `No issues found!`. + - `flutter test` PASS: `215: All tests passed!`. + - `flutter build macos` PASS: `Built build/macos/Build/Products/Release/AppSok.app (60.2MB)`. + - Bundle check: 앱 이름 `AppSok`, bundle id `com.tokilabs.appsok`, codesign `Signature=adhoc`, universal `x86_64 arm64`. + - 2026-06-14 signing readiness check: `notarytool` version `1.1.0 (39)` 확인. + - 2026-06-14 signing blocker: SSH 비대화식 세션에서 `security show-keychain-info login.keychain-db`가 `User interaction is not allowed`로 실패했고, notary profile 조회는 `keychainLocked`에서 중단됐다. + - 2026-06-14 signing blocker: Developer ID로 임시 executable 서명 probe와 `AppSok.app` 서명 시도가 `errSecInternalComponent`로 실패했다. 인증서 원문 문제가 아니라 runner Keychain/private key 접근 준비가 필요한 상태다. + - 2026-06-14 secret manager check: remote runner에는 `sops 3.13.1`과 `age`가 설치되어 있다. AppSok repo 안에는 아직 `.sops.yaml` 또는 AppSok용 encrypted secret 파일이 없고, SSH 세션에 `SOPS_AGE_KEY_FILE`도 설정되어 있지 않아 현재 CI secret retrieval 경로로 바로 사용할 수는 없다. + - 2026-06-14 secret manager setup: `scripts/setup-appsok-ci-secrets.sh`로 SOPS encrypted secret file `secrets/appsok.ci.sops.json`, age key `$HOME/.config/sops/age/appsok-ci-key.txt`, notary profile `appsok-notary`를 준비했다. + - 2026-06-14 signing readiness resolved: SOPS 복호화, keychain unlock, `appsok-notary` profile 접근, Developer ID signing probe가 통과했다. + - 2026-06-14 certified build first attempt: `AppSok.app` 본체는 Developer ID 서명됐으나 `Contents/Resources/adb-runtime/adb`가 resource executable로 남아 notarization `Invalid` 처리됐다. submission id: `57457c8f-9b0b-4e6f-a387-760ce98279c0`. + - 2026-06-14 certified build success: bundled `adb`를 먼저 Developer ID + hardened runtime + timestamp로 서명한 뒤 `AppSok.app`을 서명했고 notarization `Accepted`, stapler `worked`, `spctl` `source=Notarized Developer ID`를 확인했다. submission id: `eda0f701-78f4-45b5-91ce-58e49b0b7000`. + - 2026-06-14 certified artifact: `build/macos/Build/Products/Release/AppSok-certified.zip`, size `23M`, sha256 `1c8e89816d4cbf92bcf7c70aa40a4e36ae29efeaeb3cc64ce391c22bc6deef2a`. + - 반복 실행 명령: `scripts/build-certified-macos.sh`. + - 2026-06-14 docs/test: `docs/macos-certified-build.md`와 `test/certified_macos_scripts_test.dart`를 추가해 secret setup, certified build command, bundled `adb` signing 순서, notarization/stapling/Gatekeeper 기준을 남겼다. + - 2026-06-14 verification: `flutter test test/certified_macos_scripts_test.dart` PASS, `flutter analyze` PASS, `git diff --check` PASS. +- 선행 작업: ADB 콘솔과 logcat +- 후속 작업: Teams deep link 설치 진입 +- 확인 필요: 사용자 완료 확인과 archive 이동 승인. diff --git a/agent-roadmap/phase/workflow-integration/milestones/teams-deep-link.md b/agent-roadmap/phase/workflow-integration/milestones/teams-deep-link.md index bd1afe8..2ad72a5 100644 --- a/agent-roadmap/phase/workflow-integration/milestones/teams-deep-link.md +++ b/agent-roadmap/phase/workflow-integration/milestones/teams-deep-link.md @@ -11,7 +11,7 @@ Teams/Jenkins 알림에 포함된 `appsok://` 링크를 클릭하면 AppSok이 ## 상태 -[진행중] +[계획] ## 승격 조건 @@ -76,4 +76,5 @@ Teams/Jenkins가 만들고 AppSok이 해석할 URL 형식을 정한다. - 후속 작업: 설치 세션 이력과 오류 리포트 - 작업 현황: - 2026-06-14: `ADB 콘솔과 logcat` 완료 후 다음 활성 Milestone으로 지정했다. + - 2026-06-14: macOS build/CI와 인증 빌드 gate를 먼저 확인하기 위해 `macOS 빌드 CI와 인증 빌드` 뒤로 순서를 조정했다. - 확인 필요: Teams payload 형식, Jenkins 알림 템플릿 변경 가능 여부 diff --git a/docs/macos-certified-build.md b/docs/macos-certified-build.md new file mode 100644 index 0000000..4ad95b6 --- /dev/null +++ b/docs/macos-certified-build.md @@ -0,0 +1,68 @@ +# macOS 인증 빌드 + +AppSok의 최종 배포 후보는 Flutter 검증, macOS release build, Developer ID signing, Apple notarization, stapling, Gatekeeper assessment를 모두 통과한 ZIP으로 본다. + +## 최초 설정 + +remote Mac runner의 AppSok checkout에서 한 번 실행한다. credential을 교체할 때도 같은 명령을 다시 실행한다. + +```bash +./scripts/setup-appsok-ci-secrets.sh +``` + +입력하는 값은 Mac login/keychain password, Apple ID email, Apple app-specific password, notary profile 이름이다. 기본 notary profile 이름은 `appsok-notary`다. + +설정 스크립트는 다음 항목을 준비한다. + +- `.sops.yaml`: AppSok CI secret 파일을 암호화할 age recipient rule +- `secrets/appsok.ci.sops.json`: SOPS로 암호화된 CI 입력값 +- `$HOME/.config/sops/age/appsok-ci-key.txt`: runner local age private key +- macOS login Keychain의 notarytool credential profile + +raw password, app-specific password, decrypted SOPS payload, private key 원문은 tracked file, task log, 최종 응답에 남기지 않는다. + +## 인증 빌드 + +설정이 끝난 뒤 반복 빌드는 아래 한 줄로 실행한다. + +```bash +./scripts/build-certified-macos.sh +``` + +스크립트는 아래 순서로 실행된다. + +1. SOPS secret 복호화와 login Keychain unlock +2. `flutter pub get` +3. `flutter analyze` +4. `flutter test` +5. `flutter build macos` +6. bundled `Contents/Resources/adb-runtime/adb` Developer ID signing +7. `AppSok.app` Developer ID signing +8. `notarytool submit --wait` +9. `stapler staple` +10. `spctl --assess` +11. `AppSok-certified.zip` 생성과 checksum 출력 + +최종 산출물은 `build/macos/Build/Products/Release/AppSok-certified.zip`이다. + +## 검증 기준 + +- `flutter analyze`가 `No issues found!`로 끝난다. +- `flutter test`가 모두 통과한다. +- `notarytool submit --wait` 결과가 `Accepted`다. +- `stapler`가 ticket을 붙인다. +- `spctl --assess --type execute --verbose=4`가 Notarized Developer ID source를 보고한다. +- 최종 ZIP의 `shasum -a 256` 값을 기록한다. + +2026-06-14 검증에서는 `AppSok-certified.zip` 생성, notarization accepted, stapling, Gatekeeper assessment를 통과했다. 기록된 checksum은 `1c8e89816d4cbf92bcf7c70aa40a4e36ae29efeaeb3cc64ce391c22bc6deef2a`다. + +## 주의 사항 + +Apple notarization은 앱 번들 안의 executable resource도 별도로 검사한다. `Contents/Resources/adb-runtime/adb`가 Developer ID, hardened runtime, timestamp로 먼저 서명되지 않으면 앱 본체 서명이 정상이어도 notarization이 `Invalid` 처리될 수 있다. `scripts/build-certified-macos.sh`는 이 순서를 고정한다. + +## 문제 해결 + +- `error loading config: no matching creation rules found`: `./scripts/setup-appsok-ci-secrets.sh`를 다시 실행해 `.sops.yaml`과 encrypted secret 파일을 재생성한다. +- `HTTP status code: 401. Invalid credentials`: Apple ID, Team ID, app-specific password, notary profile 입력을 확인한다. Apple 계정 로그인 password가 아니라 app-specific password가 필요하다. +- `errSecInternalComponent` 또는 `User interaction is not allowed`: login Keychain unlock과 private key partition list 설정이 필요한 상태다. setup script를 interactive terminal에서 다시 실행한다. +- notarization log가 bundled `adb`를 지목한다: 인증 빌드 스크립트를 우회하지 말고 `./scripts/build-certified-macos.sh`로 다시 빌드한다. diff --git a/scripts/build-certified-macos.sh b/scripts/build-certified-macos.sh new file mode 100755 index 0000000..53f078b --- /dev/null +++ b/scripts/build-certified-macos.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEAM_ID="${APPSOK_TEAM_ID:-AWA82DP9WD}" +IDENTITY="${APPSOK_SIGN_IDENTITY:-Developer ID Application: Dongmyung Lee ($TEAM_ID)}" +AGE_KEY="${APPSOK_SOPS_AGE_KEY_FILE:-$HOME/.config/sops/age/appsok-ci-key.txt}" +SECRET_FILE="${APPSOK_CI_SECRET_FILE:-secrets/appsok.ci.sops.json}" + +export PATH="$HOME/SDK/flutter/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" + +cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing command: $1" >&2 + exit 1 + fi +} + +need codesign +need ditto +need flutter +need python3 +need security +need shasum +need sops +need spctl +need xcrun + +if [ ! -f "$AGE_KEY" ]; then + echo "missing SOPS age key: $AGE_KEY" >&2 + exit 1 +fi + +if [ ! -f "$SECRET_FILE" ]; then + echo "missing encrypted CI secret file: $SECRET_FILE" >&2 + exit 1 +fi + +export SOPS_AGE_KEY_FILE="$AGE_KEY" + +read_secret() { + local key="$1" + sops -d "$SECRET_FILE" | python3 -c "import json, sys; print(json.load(sys.stdin)['$key'])" +} + +KEYCHAIN_PASSWORD="$(read_secret keychain_password)" +NOTARY_PROFILE="$(read_secret notary_profile)" + +APP_PATH="build/macos/Build/Products/Release/AppSok.app" +ADB_PATH="$APP_PATH/Contents/Resources/adb-runtime/adb" +NOTARY_ZIP="build/macos/Build/Products/Release/AppSok-notary.zip" +FINAL_ZIP="build/macos/Build/Products/Release/AppSok-certified.zip" + +security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$HOME/Library/Keychains/login.keychain-db" +security set-keychain-settings -lut 21600 "$HOME/Library/Keychains/login.keychain-db" +security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$KEYCHAIN_PASSWORD" \ + "$HOME/Library/Keychains/login.keychain-db" >/dev/null + +flutter pub get +flutter analyze +flutter test +flutter build macos + +codesign --force --options runtime --timestamp --sign "$IDENTITY" "$ADB_PATH" +codesign --verify --strict --verbose=2 "$ADB_PATH" + +codesign \ + --force \ + --deep \ + --options runtime \ + --timestamp \ + --entitlements macos/Runner/Release.entitlements \ + --sign "$IDENTITY" \ + "$APP_PATH" +codesign --verify --deep --strict --verbose=2 "$APP_PATH" + +rm -f "$NOTARY_ZIP" "$FINAL_ZIP" +ditto -c -k --keepParent "$APP_PATH" "$NOTARY_ZIP" + +xcrun notarytool submit "$NOTARY_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait +xcrun stapler staple -v "$APP_PATH" +spctl --assess --type execute --verbose=4 "$APP_PATH" + +ditto -c -k --keepParent "$APP_PATH" "$FINAL_ZIP" +shasum -a 256 "$FINAL_ZIP" +ls -lh "$FINAL_ZIP" diff --git a/scripts/setup-appsok-ci-secrets.sh b/scripts/setup-appsok-ci-secrets.sh new file mode 100755 index 0000000..282542a --- /dev/null +++ b/scripts/setup-appsok-ci-secrets.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEAM_ID="${APPSOK_TEAM_ID:-AWA82DP9WD}" +DEFAULT_PROFILE="${APPSOK_NOTARY_PROFILE:-appsok-notary}" +AGE_KEY="${APPSOK_SOPS_AGE_KEY_FILE:-$HOME/.config/sops/age/appsok-ci-key.txt}" +SECRET_FILE="${APPSOK_CI_SECRET_FILE:-secrets/appsok.ci.sops.json}" + +cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing command: $1" >&2 + exit 1 + fi +} + +need age-keygen +need python3 +need security +need sops +need xcrun + +if [ ! -t 0 ]; then + echo "run this script from an interactive terminal" >&2 + exit 1 +fi + +prompt_plain() { + local label="$1" + local var="$2" + local value + printf "%s: " "$label" > /dev/tty + IFS= read -r value < /dev/tty + printf -v "$var" '%s' "$value" +} + +prompt_secret() { + local label="$1" + local var="$2" + local value + printf "%s: " "$label" > /dev/tty + stty -echo < /dev/tty + IFS= read -r value < /dev/tty + stty echo < /dev/tty + printf "\n" > /dev/tty + printf -v "$var" '%s' "$value" +} + +mkdir -p "$(dirname "$AGE_KEY")" "$(dirname "$SECRET_FILE")" + +if [ ! -f "$AGE_KEY" ]; then + age-keygen -o "$AGE_KEY" >/dev/null + chmod 600 "$AGE_KEY" +fi + +AGE_RECIPIENT="$(awk '/^# public key: / {print $4}' "$AGE_KEY")" +if [ -z "$AGE_RECIPIENT" ]; then + echo "failed to read age public key from $AGE_KEY" >&2 + exit 1 +fi + +cat > .sops.yaml < "$tmp" +import json +import os + +payload = { + "keychain_password": os.environ["KEYCHAIN_PASSWORD"], + "apple_id": os.environ["APPLE_ID"], + "app_specific_password": os.environ["APP_SPECIFIC_PASSWORD"], + "notary_profile": os.environ["NOTARY_PROFILE"], + "team_id": os.environ["TEAM_ID"], +} +print(json.dumps(payload, indent=2)) +PY + +SOPS_AGE_KEY_FILE="$AGE_KEY" \ + sops --filename-override "$SECRET_FILE" -e "$tmp" > "$SECRET_FILE" +SOPS_AGE_KEY_FILE="$AGE_KEY" sops -d "$SECRET_FILE" >/dev/null + +security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$HOME/Library/Keychains/login.keychain-db" +security set-keychain-settings -lut 21600 "$HOME/Library/Keychains/login.keychain-db" +security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$KEYCHAIN_PASSWORD" \ + "$HOME/Library/Keychains/login.keychain-db" + +xcrun notarytool store-credentials "$NOTARY_PROFILE" \ + --apple-id "$APPLE_ID" \ + --team-id "$TEAM_ID" \ + --password "$APP_SPECIFIC_PASSWORD" + +xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null + +echo "saved encrypted secret: $SECRET_FILE" +echo "saved age key: $AGE_KEY" +echo "saved notary profile: $NOTARY_PROFILE" diff --git a/secrets/appsok.ci.sops.json b/secrets/appsok.ci.sops.json new file mode 100644 index 0000000..46e6353 --- /dev/null +++ b/secrets/appsok.ci.sops.json @@ -0,0 +1,19 @@ +{ + "keychain_password": "ENC[AES256_GCM,data:dbdUnU08,iv:qiMCxcUuB5cU5dF+If44SlnyV7F8z9QH99xBbAYujUc=,tag:5vJ2wPtgZV1vymi5nlwAsQ==,type:str]", + "apple_id": "ENC[AES256_GCM,data:zQFNWgCLUAcOQLMWCKyNiabOM0rOVcEN,iv:pIAwYUz11v5I3iR3ooP4B2s0ZQO4tbpaC/RDPfZa12A=,tag:X7DK3DGB2EwbSO4bchmWxw==,type:str]", + "app_specific_password": "ENC[AES256_GCM,data:kF14kJqVBb7/R6CdnGWEUgEd1w==,iv:rUsHkTp7ou+xgHRfBT8CvYJ8wQfOCdGa+JdmCtPNjIw=,tag:nT06ha1S6toRommwIbBgxg==,type:str]", + "notary_profile": "ENC[AES256_GCM,data:8GuRGFIMKEGMq+wHgg==,iv:N3Cg0i138Brc3RdUSzX6ZFnUGzxZEcBf8gmUPdayxqU=,tag:Q7cFKyTgWvT29bkW9czabA==,type:str]", + "team_id": "ENC[AES256_GCM,data:vdQ+EGiKNzBfPw==,iv:6/X2mZEv0oFlTUAnXKJpSZuNAELe0Op+zJm9ZITfIVA=,tag:LdBWE9y0sMO5jB/RehNKUA==,type:str]", + "sops": { + "age": [ + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmOFBGQlcyMlZaS2ltVzNr\nQ0ZqZU43UW11cHQrRGtmT2Y2WS9kLyt2ZUFBCkVaZW5QZTdWeGJob2U0WVR0emJO\ndzY1ZUZTNFRzUFBRcmhvdCttYWp2MUkKLS0tIE9ncGprRXZRQTBzUzIwcjlwR2cx\nNUpQNGZYVk14bm9zMkdnNEg3N3FXcUkKD8r/akXXQ6ZVWiKBA62RlB9kB8mXUgBd\nCreJtxzJG4A+9Xla6UdzJPZYSP3IY5JPQoWUtzc0xXbBHfCSmpWoww==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age1l4fylw9cvw49urz84pa02gjupnpnpz8qp6k0t7225wt8lukkg3cs6qflph" + } + ], + "lastmodified": "2026-06-14T07:00:35Z", + "mac": "ENC[AES256_GCM,data:UjOl2IcvEm9P+EldqwW5kj3vkUFmW5zylLXgSjZYDKvAZIHHW5JwgerKDqw+90AdV9dl5vIIReXRWiHepQQugkofJmsAddHRhK5DI8Xam9X1cC87S28phAp5iQm1xMF55jF2gsuQ5FVtDZ2W5ZX3epGxhLLUXk2xK+mtyUj9i9w=,iv:d+Gc6HA8VRUjFoNsMjYI3ckqujiMeYjAkhIyEWoMvWk=,tag:jo0sglvMW1P3asBP3giIpA==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.13.1" + } +} diff --git a/test/certified_macos_scripts_test.dart b/test/certified_macos_scripts_test.dart new file mode 100644 index 0000000..c63f2f5 --- /dev/null +++ b/test/certified_macos_scripts_test.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('certified macOS build scripts', () { + test('secret setup stores repeatable encrypted CI inputs', () { + final script = _readScript('scripts/setup-appsok-ci-secrets.sh'); + + expect(script, contains(r'age-keygen -o "$AGE_KEY"')); + expect( + script, + contains(r'path_regex: secrets/appsok\\.ci\\.sops\\.json$'), + ); + expect( + script, + contains(r'sops --filename-override "$SECRET_FILE" -e "$tmp"'), + ); + expect(script, contains(r'SOPS_AGE_KEY_FILE="$AGE_KEY" sops -d')); + expect(script, contains('security unlock-keychain')); + expect(script, contains('security set-key-partition-list')); + expect(script, contains('xcrun notarytool store-credentials')); + expect(script, contains('xcrun notarytool history --keychain-profile')); + }); + + test('certified build keeps Flutter gates before release packaging', () { + final script = _readScript('scripts/build-certified-macos.sh'); + + _expectOrder(script, 'flutter pub get', 'flutter analyze'); + _expectOrder(script, 'flutter analyze', 'flutter test'); + _expectOrder(script, 'flutter test', 'flutter build macos'); + _expectOrder(script, 'flutter build macos', 'codesign --force'); + }); + + test('certified build signs bundled adb before app notarization', () { + final script = _readScript('scripts/build-certified-macos.sh'); + + expect( + script, + contains(r'ADB_PATH="$APP_PATH/Contents/Resources/adb-runtime/adb"'), + ); + expect( + script, + contains( + r'codesign --force --options runtime --timestamp --sign "$IDENTITY" "$ADB_PATH"', + ), + ); + expect( + script, + contains(r'codesign --verify --strict --verbose=2 "$ADB_PATH"'), + ); + _expectOrder( + script, + r'codesign --force --options runtime --timestamp --sign "$IDENTITY" "$ADB_PATH"', + '--entitlements macos/Runner/Release.entitlements', + ); + }); + + test('certified build notarizes, staples, assesses, and zips artifact', () { + final script = _readScript('scripts/build-certified-macos.sh'); + + expect( + script, + contains( + r'xcrun notarytool submit "$NOTARY_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait', + ), + ); + expect(script, contains(r'xcrun stapler staple -v "$APP_PATH"')); + expect( + script, + contains(r'spctl --assess --type execute --verbose=4 "$APP_PATH"'), + ); + expect( + script, + contains( + 'FINAL_ZIP="build/macos/Build/Products/Release/AppSok-certified.zip"', + ), + ); + expect(script, contains(r'shasum -a 256 "$FINAL_ZIP"')); + + _expectOrder( + script, + r'xcrun notarytool submit "$NOTARY_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait', + r'xcrun stapler staple -v "$APP_PATH"', + ); + _expectOrder( + script, + r'xcrun stapler staple -v "$APP_PATH"', + r'spctl --assess --type execute --verbose=4 "$APP_PATH"', + ); + _expectOrder( + script, + r'spctl --assess --type execute --verbose=4 "$APP_PATH"', + r'ditto -c -k --keepParent "$APP_PATH" "$FINAL_ZIP"', + ); + }); + }); +} + +String _readScript(String path) => File(path).readAsStringSync(); + +void _expectOrder(String source, String before, String after) { + final beforeIndex = _requiredIndex(source, before); + final afterIndex = _requiredIndex(source, after); + expect( + beforeIndex, + lessThan(afterIndex), + reason: '"$before" must appear before "$after"', + ); +} + +int _requiredIndex(String source, String needle) { + final index = source.indexOf(needle); + expect(index, greaterThanOrEqualTo(0), reason: 'missing "$needle"'); + return index; +}