diff --git a/.github/workflows/firebase-release.yml b/.github/workflows/firebase-release.yml index 7faf3bbe..e6f04889 100644 --- a/.github/workflows/firebase-release.yml +++ b/.github/workflows/firebase-release.yml @@ -269,6 +269,7 @@ jobs: packages: read outputs: relevant: ${{ steps.plan.outputs.relevant }} + delivery: ${{ steps.plan.outputs.delivery }} eligible: ${{ steps.plan.outputs.eligible }} runner: ${{ steps.plan.outputs.runner }} xcode: ${{ steps.plan.outputs.xcode }} @@ -282,8 +283,12 @@ jobs: - run: dotnet tool restore - name: Export Sign-In and integration dependency closures run: | - dotnet tool run dotnet-cake -- signin_publish.cake - dotnet tool run dotnet-cake -- --target=firebase-publish-metadata --producer=legacy + if python3 -c 'import json,sys; sys.exit(json.load(open("eng/signin-release.json"))["nativeBuild"].get("delivery") != "firebase13-static")'; then + python3 scripts/firebase-release/release.py --channel signin signin13-metadata + else + dotnet tool run dotnet-cake -- signin_publish.cake + dotnet tool run dotnet-cake -- --target=firebase-publish-metadata --producer=legacy + fi - name: Release tooling tests run: python3 -m unittest discover -s tests/firebase-release -v - name: Plan Sign-In validation and release @@ -323,7 +328,11 @@ jobs: signin-validate: needs: signin-plan - if: needs.signin-plan.outputs.relevant == 'true' && github.event_name != 'pull_request' + if: >- + needs.signin-plan.outputs.relevant == 'true' && + (github.event_name != 'pull_request' || + (needs.signin-plan.outputs.delivery == 'firebase13-static' && + github.event.pull_request.head.repo.full_name == github.repository)) # Native builds run only on the maintainers' self-hosted Mac (docs/SELF_HOSTED_MACOS_RUNNER.md). runs-on: [self-hosted, macOS, ARM64, '${{ needs.signin-plan.outputs.runner }}'] timeout-minutes: 360 @@ -334,6 +343,7 @@ jobs: BUILD_COMMIT: ${{ github.sha }} BUILD_NUMBER: ${{ github.run_number }} XCODE_VERSION: ${{ needs.signin-plan.outputs.xcode }} + SIGNIN_DELIVERY: ${{ needs.signin-plan.outputs.delivery }} steps: - uses: actions/checkout@v5 - uses: actions/download-artifact@v7 @@ -345,10 +355,12 @@ jobs: - uses: actions/setup-dotnet@v5 with: global-json-file: global.json + - name: Install Apple workloads + run: dotnet workload install ios maccatalyst - name: Install pinned producer tools + if: env.SIGNIN_DELIVERY != 'firebase13-static' run: | dotnet tool restore - dotnet workload install ios maccatalyst pin=$(python3 -c 'import json; print(json.load(open("eng/signin-release.json"))["nativeBuild"]["cocoapods"])') # Install the pinned CocoaPods into a job-local gem home so the Mac's own gems are untouched. export GEM_HOME="$RUNNER_TEMP/gems" @@ -360,8 +372,10 @@ jobs: id: unit-tests run: python3 -m unittest discover -s tests/firebase-release -v - name: Build and pack Sign-In closure + if: env.SIGNIN_DELIVERY != 'firebase13-static' run: dotnet tool run dotnet-cake -- --target=nuget --names=Google.SignIn - name: Inspect and stage Sign-In candidate + if: env.SIGNIN_DELIVERY != 'firebase13-static' id: package-inspection env: GH_TOKEN: ${{ github.token }} @@ -369,8 +383,10 @@ jobs: python3 scripts/firebase-release/release.py --channel signin prepare --online python3 scripts/signin-release/validate.py - name: Standalone and transitive offline consumers + if: env.SIGNIN_DELIVERY != 'firebase13-static' run: python3 scripts/signin-release/consumers.py - name: Reuse published Firebase 12 integration fixtures + if: env.SIGNIN_DELIVERY != 'firebase13-static' id: fixtures env: # GitHub Packages reads need a repository token; fork pull requests use nuget.org alone. @@ -379,16 +395,40 @@ jobs: dotnet tool run dotnet-cake -- --target=firebase-publish-metadata --producer=legacy python3 scripts/signin-release/integration_feed.py --fetch-published - name: Build Firebase 12 integration fixtures only - if: steps.fixtures.outputs.source != 'published' + if: env.SIGNIN_DELIVERY != 'firebase13-static' && steps.fixtures.outputs.source != 'published' run: dotnet tool run dotnet-cake -- --target=nuget --names=Firebase.Auth,Firebase.Analytics,Firebase.CloudFirestore --producer=legacy - name: Stage Firebase 12 integration feed + if: env.SIGNIN_DELIVERY != 'firebase13-static' run: python3 scripts/signin-release/integration_feed.py - name: Combined Sign-In and Firebase 12 consumers + if: env.SIGNIN_DELIVERY != 'firebase13-static' run: python3 scripts/signin-release/consumers.py --feed output/signin-combined --combined + - name: Build static Sign-In and acquire Firebase 13 fixtures + if: env.SIGNIN_DELIVERY == 'firebase13-static' + env: + GH_TOKEN: ${{ github.token }} + run: | + rm -rf output/firebase13 output/signin-packages output/signin-candidate output/signin-results + mkdir -p "$RUNNER_TEMP/signin-no-pod" + printf '#!/bin/sh\necho Unexpected producer CocoaPods invocation >&2\nexit 97\n' > "$RUNNER_TEMP/signin-no-pod/pod" + chmod +x "$RUNNER_TEMP/signin-no-pod/pod" + PATH="$RUNNER_TEMP/signin-no-pod:$PATH" python3 scripts/firebase-release/release.py --channel signin signin13-produce + - name: Inspect and stage static Sign-In candidate + if: env.SIGNIN_DELIVERY == 'firebase13-static' + id: static-package-inspection + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/firebase-release/release.py --channel signin prepare --online --package-dir output/signin-packages + - name: Standalone and transitive static Sign-In consumers + if: env.SIGNIN_DELIVERY == 'firebase13-static' + run: python3 tools/firebase13/consumers.py --scope signin --signin-candidate output/signin-candidate + - name: Combined Sign-In and Firebase 13 consumers + if: env.SIGNIN_DELIVERY == 'firebase13-static' + run: python3 tools/firebase13/consumers.py --scope combined --with-signin --signin-candidate output/signin-candidate - name: Seal Sign-In validation evidence and approval summary env: UNIT_TEST_OUTCOME: ${{ steps.unit-tests.outcome }} - PACKAGE_INSPECTION_OUTCOME: ${{ steps.package-inspection.outcome }} + PACKAGE_INSPECTION_OUTCOME: ${{ steps.static-package-inspection.outcome == 'success' && steps.static-package-inspection.outcome || steps.package-inspection.outcome }} run: | python3 scripts/firebase-release/release.py --channel signin collect_validation --results-dir output/signin-results python3 scripts/firebase-release/release.py --channel signin finalize --results output/signin-results/validation.json @@ -405,6 +445,7 @@ jobs: path: | output/signin-results output/signin-resolution + output/firebase13-results/*signin* retention-days: 90 signin-validation: @@ -418,9 +459,11 @@ jobs: RELEVANT: ${{ needs.signin-plan.outputs.relevant }} VALIDATE_RESULT: ${{ needs.signin-validate.result }} EVENT_NAME: ${{ github.event_name }} + DELIVERY: ${{ needs.signin-plan.outputs.delivery }} + TRUSTED_PR: ${{ github.event.pull_request.head.repo.full_name == github.repository }} run: | test "$PLAN_RESULT" = success - if [ "$RELEVANT" = true ] && [ "$EVENT_NAME" != pull_request ]; then + if [ "$RELEVANT" = true ] && { [ "$EVENT_NAME" != pull_request ] || { [ "$DELIVERY" = firebase13-static ] && [ "$TRUSTED_PR" = true ]; }; }; then test "$VALIDATE_RESULT" = success elif [ "$RELEVANT" = true ]; then test "$VALIDATE_RESULT" = skipped @@ -457,8 +500,12 @@ jobs: path: output/signin-candidate - name: Re-export allowed Sign-In scope run: | - dotnet tool restore - dotnet tool run dotnet-cake -- signin_publish.cake + if python3 -c 'import json,sys; sys.exit(json.load(open("eng/signin-release.json"))["nativeBuild"].get("delivery") != "firebase13-static")'; then + python3 scripts/firebase-release/release.py --channel signin signin13-metadata + else + dotnet tool restore + dotnet tool run dotnet-cake -- signin_publish.cake + fi - name: Configure authenticated GitHub Packages source env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/firebase13-xbd.yml b/.github/workflows/firebase13-xbd.yml index 6544cea7..0b1b350e 100644 --- a/.github/workflows/firebase13-xbd.yml +++ b/.github/workflows/firebase13-xbd.yml @@ -46,12 +46,16 @@ concurrency: jobs: schema: + outputs: + signin-delivery: ${{ steps.signin-delivery.outputs.mode }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-dotnet@v5 with: global-json-file: global.json + - id: signin-delivery + run: python3 -c 'import json; print("mode=" + json.load(open("eng/signin-release.json"))["nativeBuild"].get("delivery", "legacy"))' >> "$GITHUB_OUTPUT" - run: python3 -m unittest discover -s tests/firebase13 -v - run: python3 tools/firebase13/native.py native-assets-validate - name: Locked legacy producer and published-fingerprint regressions @@ -120,9 +124,10 @@ jobs: legacy-signin: # This validates package production/consumption only; it cannot publish or approve deployment. if: >- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository) + needs.schema.outputs.signin-delivery != 'firebase13-static' && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository)) needs: schema runs-on: [self-hosted, macOS, ARM64, xcode-27.1] timeout-minutes: 180 diff --git a/.gitignore b/.gitignore index 129df583..d5506b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ __pycache__/ *.pyc !tools/legacy/ + +!tools/signin-native/ +!tools/SignInNative/ +tools/SignInNative/.build/ +tools/SignInNative/.swiftpm/ diff --git a/docs/SIGNIN_FIREBASE_13_COMPATIBILITY.md b/docs/SIGNIN_FIREBASE_13_COMPATIBILITY.md new file mode 100644 index 00000000..41486e05 --- /dev/null +++ b/docs/SIGNIN_FIREBASE_13_COMPATIBILITY.md @@ -0,0 +1,48 @@ +# Sign-In 10.1.0 binding revision for Firebase 13 + +The companion candidate `AdamE.Google.iOS.SignIn` 10.1.0.1 uses Google's native +Sign-In 10.1.0, AppAuth 3.0.0, and GTMAppAuth 6.0.0. Its exact shared dependencies +match the Firebase 13.1.1 archive. Use the published Sign-In 10.1.0 package for +the existing Firebase 12 package line. + +The published Sign-In package contains dynamic libraries that load AppCheckCore +and GTMSessionFetcher by framework install name. Firebase 13 links those +libraries statically. Widening the dependency ranges would therefore leave a +native loader mismatch. This revision compiles only the private Sign-In closure +as static frameworks and continues to use the Firebase NuGets for shared +libraries. SwiftPM runs on the package producer's Mac. The application consumes +the packed Sign-In frameworks and bundles, and obtains shared binaries through +the Firebase packages' normal XBD targets. + +To reproduce the companion candidate on a Mac with Xcode 27.1, stage the 23 +Firebase 13.1.1 candidate packages in `output/firebase13`, then run: + +```sh +python3 tools/signin-native/build.py +dotnet restore source/Google/SignIn/SignIn.csproj \ + --source "$PWD/output/firebase13" --source https://api.nuget.org/v3/index.json +dotnet pack source/Google/SignIn/SignIn.csproj --no-restore -c Release -o output/firebase13 +python3 tools/firebase13/consumers.py --scope combined --with-signin +``` + +The combined consumer checks the exact restored packages, direct and transitive +builds, required resource bundles, absence of embedded dynamic copies of the +static frameworks, and secret-free simulator calls. A credentialed Sign-In, +token refresh, and Firebase Auth exchange on a device remain separate validation. + +The existing Firebase CI and release workflow runs this static producer and +both consumer lanes on trusted pull requests. It stages the five-package +Sign-In closure and seals its hashes and checks. Firebase products used for +the combined consumer stay outside the Sign-In publication scope. + +The companion configuration leaves release eligibility disabled. After Firebase +13.1.1 is released, the maintainer can enable Sign-In 10.1.0.1 in +`eng/signin-release.json`. Eligible validation uses the published Firebase +release manifest and checks matching fixture payloads on both registries. +The existing protected deployment job rechecks the four support packages on +both feeds, so only the new Sign-In package can be pushed by this channel. +The publisher uses the sealed package files from validation. + +The legacy producer rejects requests to build the static Sign-In revision from +its frozen CocoaPods baseline. Use the Sign-In release workflow or the explicit +commands above for that revision. diff --git a/eng/signin-release.json b/eng/signin-release.json index cf324a20..6c9947ff 100644 --- a/eng/signin-release.json +++ b/eng/signin-release.json @@ -1,8 +1,8 @@ { - "releaseVersion": "10.1.0", + "releaseVersion": null, "nativeBuild": { + "delivery": "firebase13-static", "runner": "xcode-27.1", - "xcode": "27.1", - "cocoapods": "1.17.0" + "xcode": "27.1" } } diff --git a/scripts/firebase-release/release.py b/scripts/firebase-release/release.py index 42a415f0..a1b2c0fc 100644 --- a/scripts/firebase-release/release.py +++ b/scripts/firebase-release/release.py @@ -57,6 +57,21 @@ def configure_channel(channel): 'firestore-pipeline', 'firestore-aggregate', 'firestore-filters']) +def signin13_mode(settings): + return CHANNEL == 'signin' and settings.get('delivery') == 'firebase13-static' + + +def signin13(): + import importlib.util + path = str(ROOT / 'tools/firebase13') + if path not in sys.path: + sys.path.insert(0, path) + spec = importlib.util.spec_from_file_location('signin13_release', ROOT / 'scripts/firebase-release/signin13.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def validate_scope(packages): if CHANNEL == 'signin': require({p['id'] for p in packages} == SIGNIN_IDS and len(packages) == len(SIGNIN_IDS), @@ -95,6 +110,9 @@ def validate_manifest_channel(manifest): def require_signin_predecessor(release_version, latest): native = '.'.join(map(str, version(release_version)[:3])) require(native in SIGNIN_VERSIONS, 'Unexpected Sign-In release version') + if version(release_version) == version('10.1.0.1'): + require(latest and version(latest['releaseVersion']) in (version('10.1.0'), version(release_version)), + 'Missing preceding Sign-In release: expected 10.1.0') if latest and version(latest['releaseVersion']) == version(release_version): return # The caller separately verifies the source commit for completed retries. previous = None if latest is None else '.'.join(map(str, version(latest['releaseVersion'])[:3])) @@ -148,7 +166,7 @@ def native_build(config): # Native builds run on the self-hosted Mac labelled with its pinned Xcode (docs/SELF_HOSTED_MACOS_RUNNER.md). require(bool(re.fullmatch(r'xcode-\d+(?:\.\d+){1,2}', settings['runner'])), 'Expected a pinned self-hosted runner label') require(bool(re.fullmatch(r'\d+(?:\.\d+){1,2}', settings['xcode'])), 'Expected a pinned stable Xcode version') - if CHANNEL == 'signin': + if CHANNEL == 'signin' and not signin13_mode(settings): require(version('1.12.0') <= version(settings['cocoapods']) < version('2.0.0'), 'Unsupported CocoaPods runtime') return settings @@ -226,6 +244,8 @@ def legacy_inputs(root=None): def snapshot(metadata): """Fingerprint the source tree actually used for production, including legacy inputs.""" + if CHANNEL == 'signin' and any(p.get('delivery') == 'firebase13-static' for p in metadata): + return signin13().snapshot(sys.modules[__name__], metadata) legacy=legacy_inputs() if legacy and legacy.matches_metadata(metadata,ROOT): with legacy.source_tree(ROOT) as source: @@ -359,7 +379,9 @@ def validation_reasons(files, packages, channel, integration_metadata=()): 'tests/E2E/Firebase.Foundation/', 'tests/crashlytics/')) else: exact.add('signin_publish.cake') - prefixes.append('scripts/signin-release/') + prefixes.extend(('scripts/signin-release/', 'tools/SignInNative/', 'tools/signin-native/', + 'tools/firebase13/', 'source/Firebase/native/')) + exact.add('source/Firebase/native-assets.json') for package in packages: project = ROOT / package['project'] for element in ET.parse(project).iter(): @@ -415,11 +437,19 @@ def plan(args): reasons.append('missing comparison history') if not reasons: files = run('git', 'diff', '--name-only', args.base, 'HEAD').splitlines() - integration = read(args.integration_metadata) if CHANNEL == 'signin' else () + integration = () + if CHANNEL == 'signin': + integration = (signin13().integration_metadata(sys.modules[__name__]) + if signin13_mode(config['nativeBuild']) else read(args.integration_metadata)) reasons = validation_reasons(files, packages, CHANNEL, integration) old_config = git_file(args.base, CONFIG) if args.base else None triggered = args.manual or old_config is None or old_config.get('releaseVersion') != release_version eligible = bool(release_version and triggered and (not latest or version(release_version) > version(latest['releaseVersion']))) + if signin13_mode(config['nativeBuild']): + if release_version: + require(version(release_version) == version('10.1.0.1'), 'Unexpected static Sign-In binding revision') + if os.environ.get('GITHUB_EVENT_NAME') == 'pull_request': + eligible = False if eligible: reasons.append('eligible release') result = {'schemaVersion': 1, 'sourceCommit': run('git', 'rev-parse', 'HEAD'), @@ -434,6 +464,7 @@ def plan(args): if os.environ.get('GITHUB_OUTPUT'): with open(os.environ['GITHUB_OUTPUT'], 'a') as output: output.write(f"relevant={str(result['relevant']).lower()}\neligible={str(eligible).lower()}\n") + output.write(f"delivery={result['nativeBuild'].get('delivery', 'legacy')}\n") output.write(f"runner={result['nativeBuild']['runner']}\nxcode={result['nativeBuild']['xcode']}\n") print(f"{LABEL} validation required: {result['relevant']}; release eligible: {eligible}; changed packages: {len(changed)}") print('Validation reasons: ' + (', '.join(reasons) if reasons else 'no matching inputs changed')) @@ -518,6 +549,8 @@ def check_feeds(args): inspect_package(data, p, packages, published_commit_requirement(manifest, p['id'])) except ValueError as error: row['errors'].append(f'{feed.name}: {error}') + if signin13_mode(manifest.get('nativeBuild', {})) and p['id'] in SIGNIN_SHARED_IDS and len(available) != len(registries): + row['errors'].append('Requires the Firebase 13 package on both feeds') if len({payload_hash(data) for data in available}) > 1: row['errors'].append('Feeds disagree') report.append(row) @@ -557,6 +590,9 @@ def inspect_package(data, package, packages, new_commit=None): require(info['id'] == package['id'] and version(info['version']) == version(package['version']), 'Package identity mismatch') if new_commit: require(info['commit'] == new_commit, f"Wrong repository commit: {info['id']}") + if CHANNEL == 'signin' and package.get('delivery') in ('firebase13-static', 'firebase-xbd'): + signin13().inspect(sys.modules[__name__], data, package, packages) + return info['dependencies'] for dependency in info['dependencies']: if dependency['id'].startswith('AdamE.'): require(dependency['id'] in packages, f"Out-of-scope dependency: {dependency['id']}") @@ -621,6 +657,10 @@ def prepare(args): for p in manifest['packages']: existing = {f.name: f.fetch(p) for f in registries} available = [data for data in existing.values() if data is not None] + if registries and signin13_mode(manifest.get('nativeBuild', {})) and p['id'] in SIGNIN_SHARED_IDS: + require(len(available) == len(registries), f"Requires Firebase 13 support package on both feeds: {p['id']}") + require(payload_hash(candidates[p['id']].read_bytes()) == payload_hash(available[0]), + f"Firebase integration fixture differs from the feeds: {p['id']}") if available: require(len({payload_hash(data) for data in available}) == 1, f"Feeds disagree: {p['id']}") data = available[0] if available else candidates[p['id']].read_bytes() @@ -656,7 +696,9 @@ def finalize(args): label = f"[{p['id']}](https://www.nuget.org/packages/{p['id']}/{normalized(p['version'])})" lines.append(f"| {label} | {p['version']} | {', '.join(p['destinations']) or 'none'} | {p['reused']} |") lines += ['', f'[GitHub Packages](https://github.com/{REPO.split("/")[0]}?tab=packages&repo_name={REPO.split("/")[1]})'] - if CHANNEL == 'signin': + if signin13_mode(manifest.get('nativeBuild', {})): + lines += ['', 'Native Sign-In 10.1.0, statically linked against the Firebase 13 support packages.'] + elif CHANNEL == 'signin': pods = sorted({(pod['Name'], pod['Version']) for p in manifest['packages'] for pod in p['pods']}) lines += ['', 'Native CocoaPods versions: ' + ', '.join(f'{name} {value}' for name, value in pods)] (folder / 'release-notes.md').write_text('\n'.join(lines) + '\n') @@ -676,10 +718,14 @@ def collect_validation(args): cases = result.get('Cases', []) require(cases and all(c.get('Success') is True for c in cases), f'{name} has missing or failed cases') if CHANNEL == 'signin': - require(result.get('sourceCommit') == os.environ.get('BUILD_COMMIT'), 'Consumer evidence has wrong source commit') - require(result.get('packageHashes') and result.get('Checks'), 'Missing Sign-In consumer evidence') manifest = read(Path(args.package_dir) / 'manifest.json') validate_manifest_channel(manifest) + if signin13_mode(manifest.get('nativeBuild', {})): + signin13().validate_consumer(sys.modules[__name__], result, manifest, name) + results[name] = 'passed' + continue + require(result.get('sourceCommit') == os.environ.get('BUILD_COMMIT'), 'Consumer evidence has wrong source commit') + require(result.get('packageHashes') and result.get('Checks'), 'Missing Sign-In consumer evidence') require(result['packageHashes'] == {p['id']: p['sha256'] for p in manifest['packages']}, 'Consumer evidence has wrong packages') require(result.get('Lane') == name, 'Consumer evidence has wrong lane') require(signin_checks(result.get('RuntimeArchitecture', 'arm64')) <= set(result['Checks']) and any(c.startswith('PASS classes=') for c in result['Checks']), @@ -708,7 +754,7 @@ def collect_validation(args): write(Path(args.results_dir) / 'validation.json', results) -def publish_packages(folder, packages, registries, wait_seconds=1800, poll_seconds=15): +def publish_packages(folder, packages, registries, wait_seconds=1800, poll_seconds=15, reuse_only=()): # Cache verified identities, not package bytes. Existing immutable versions need one download. verified = set() pending = [] @@ -716,6 +762,7 @@ def publish_packages(folder, packages, registries, wait_seconds=1800, poll_secon for feed in registries: data = feed.fetch(p) require(data is None or payload_hash(data) == p['payloadHash'], f"Existing package conflict: {feed.name}/{p['id']}") + require(data is not None or p['id'] not in reuse_only, f"Required dependency missing: {feed.name}/{p['id']}") if data is not None: verified.add((feed.name, p['id'], p['version'])) # Submit the entire dependency-ordered set before waiting for registry indexing. @@ -782,7 +829,11 @@ def publish(args): expected_commit = manifest['sourceCommit'] if not p['reused'] else published_commit_requirement(manifest, p['id']) inspect_package(data, allowed[p['id']], allowed, expected_commit) registries = feeds() - publish_packages(folder, manifest['packages'], registries) + if signin13_mode(read(ROOT / CONFIG)['nativeBuild']): + require(signin13_mode(manifest['nativeBuild']), 'Wrong Sign-In delivery evidence') + publish_packages(folder, manifest['packages'], registries, reuse_only=SIGNIN_SHARED_IDS) + else: + publish_packages(folder, manifest['packages'], registries) tag = TAG_PREFIX + manifest['releaseVersion'] # gh creates the tag at the exact tested SHA. Existing tags must never be moved. tags = github('git/matching-refs/tags/' + urllib.parse.quote(tag, safe='')) @@ -847,11 +898,23 @@ def main(): p.add_argument('--package-dir', default=f'output/{CHANNEL}-candidate') p.add_argument('--metadata', default=f'output/{CHANNEL}-metadata.json') sub.add_parser('probe-feeds') + sub.add_parser('signin13-metadata') + sub.add_parser('signin13-produce') sub.add_parser('last-validated') args = parser.parse_args() if args.command == 'snapshot': packages = [{k: p[k] for k in ('id', 'version', 'fingerprint')} for p in snapshot(read(args.metadata))] write(args.output, {'schemaVersion': 1, 'published': False, 'packages': packages}) + elif args.command in ('signin13-metadata', 'signin13-produce'): + require(signin13_mode(read(ROOT / CONFIG)['nativeBuild']), 'Firebase 13 Sign-In mode is required') + adapter = signin13() + release = sys.modules[__name__] + if args.command == 'signin13-metadata': + write(ROOT / 'output/signin-metadata.json', adapter.metadata(release)) + else: + plan = read(ROOT / 'output/signin-plan.json') + require(plan['sourceCommit'] == run('git', 'rev-parse', 'HEAD'), 'Sign-In plan is for another commit') + adapter.produce(release, plan, ROOT / 'output/firebase13', ROOT / 'output/signin-packages') elif args.command == 'probe-feeds': # Small existing dependency exercises actual download authorization, not just the public index. for feed in feeds(): diff --git a/scripts/firebase-release/signin13.py b/scripts/firebase-release/signin13.py new file mode 100644 index 00000000..44b18919 --- /dev/null +++ b/scripts/firebase-release/signin13.py @@ -0,0 +1,225 @@ +"""Release adapter for the static Sign-In binding and Firebase 13 support packages.""" +import io +import json +import os +from pathlib import Path +import plistlib +import re +import shutil +import struct +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +import zipfile + +ROOT = Path(__file__).resolve().parents[2] +PROJECT = 'source/Google/SignIn/SignIn.csproj' +DELIVERY = 'firebase13-static' +FRAMEWORKS = {'GoogleSignIn', 'AppAuth', 'AppAuthCore', 'GTMAppAuth'} +BUNDLES = {'GoogleSignIn_GoogleSignIn.bundle', 'AppAuth_AppAuth.bundle', + 'AppAuth_AppAuthCore.bundle', 'GTMAppAuth_GTMAppAuth.bundle'} + + +def metadata(release): + from native import order, validate_schema + manifest = release.read(ROOT / 'source/Firebase/native-assets.json') + packages = validate_schema(manifest) + support = order(packages, ','.join(p['id'] for p in packages.values() + if p['package'] in release.SIGNIN_SHARED_IDS)) + release.require({p['package'] for p in support} == release.SIGNIN_SHARED_IDS, + 'Unexpected Sign-In support closure') + records = [ + {'key': p['id'], 'id': p['package'], 'version': p['version'], 'project': p['project'], + 'dependencies': p['dependencies'], 'firebase': False, 'delivery': 'firebase-xbd', + 'archive': manifest['source'], 'nativePackage': p, 'pods': []} + for p in support + ] + project = ET.parse(ROOT / PROJECT) + refs = [(p.get('Include'), p.get('Version')) for p in project.iter('PackageReference')] + expected = {p['id']: '[' + release.normalized(p['version']) + ']' for p in records} + release.require(len(refs) == len(expected) and dict(refs) == expected, + 'Sign-In package references differ from the exact Firebase 13 closure') + records.append({'key': 'Google.SignIn', 'id': project.findtext('.//PackageId'), + 'version': project.findtext('.//PackageVersion'), 'project': PROJECT, + 'dependencies': [p['id'] for p in records], 'firebase': False, + 'delivery': DELIVERY, 'firebaseVersion': manifest['releaseVersion'], 'pods': []}) + release.validate_scope(records) + return records + + +def integration_metadata(release): + records = release.read(ROOT / 'source/Firebase/native-assets.json')['packages'] + return [{**p, 'id': p['package']} for p in records] + + +def snapshot(release, packages): + release.require(packages == metadata(release), 'Sign-In 13 metadata differs from repository inputs') + tracked = release.run('git', 'ls-files').splitlines() + shared = {'global.json', 'Directory.Build.props', 'source/AssemblyInfo.cs'} + recipe = {p for p in tracked if p.startswith(('tools/signin-native/', 'tools/SignInNative/'))} + common = {p for p in tracked if p.startswith(('tools/firebase13/', 'source/Firebase/native/'))} + result = [] + for package in packages: + directory = Path(package['project']).parent.as_posix() + '/' + files = {p for p in tracked if p.startswith(directory)} | shared + if package['delivery'] == DELIVERY: + files |= recipe | {'docs/NUGET_README.md', 'icons/googleiossignin_128x128.png'} + else: + # Include the shared producer and common targets, while taking this + # package's declaration and the archive identity from its metadata. + files |= {p for p in common if p.endswith(('.py', 'Common.targets', 'NOTICES.txt', '.proj'))} + data = {'package': package, 'files': {p: release.digest((ROOT / p).read_bytes()) for p in sorted(files)}} + if package['delivery'] == DELIVERY: + data['nativeBuild'] = release.read(ROOT / release.CONFIG)['nativeBuild'] + result.append({**package, 'fingerprint': release.digest(json.dumps(data, sort_keys=True).encode())}) + return result + + +def static_archive(data): + if data.startswith(b'!\n'): + return True + if data[:4] != b'\xca\xfe\xba\xbe' or len(data) < 8: + return False + count = struct.unpack_from('>I', data, 4)[0] + if not 1 <= count <= 2 or len(data) < 8 + count * 20: + return False + return all(data[offset:offset + 8] == b'!\n' + for offset in (struct.unpack_from('>I', data, 16 + i * 20)[0] for i in range(count))) + + +def inspect(release, data, package, packages): + if package['delivery'] == 'firebase-xbd': + from native import inspect_package + with tempfile.TemporaryDirectory() as directory: + file = Path(directory) / (package['id'] + '.nupkg') + file.write_bytes(data) + inspect_package(file, package['nativePackage']) + return + info = release.package_info(data) + with zipfile.ZipFile(io.BytesIO(data)) as archive: + spec = ET.fromstring(archive.read(next(n for n in info['files'] if n.endswith('.nuspec')))) + groups = spec.findall('./{*}metadata/{*}dependencies/{*}group') + expected = {p: '[' + release.normalized(packages[p]['version']) + ']' for p in package['dependencies']} + release.require(len(groups) == 2, 'Expected two Sign-In dependency groups') + for group in groups: + deps = [(d.get('id'), d.get('version')) for d in group.findall('{*}dependency')] + release.require(len(deps) == len(expected) and dict(deps) == expected, + 'Sign-In dependency groups differ from the Firebase 13 closure') + for platform, variants in [('ios', {'', 'simulator'}), ('maccatalyst', {'maccatalyst'})]: + prefix = f'lib/net10.0-{platform}26.0/Google.SignIn' + release.require(prefix + '.dll' in info['files'], f'Missing Sign-In {platform} assembly') + with zipfile.ZipFile(io.BytesIO(archive.read(prefix + '.resources.zip'))) as native: + references = ET.fromstring(native.read('manifest')).findall('NativeReference') + release.require(len(references) == len(FRAMEWORKS) and + {r.get('Name') for r in references} == {f + '.xcframework' for f in FRAMEWORKS}, + 'Unexpected packed Sign-In framework ownership') + props = ET.parse(ROOT / Path(PROJECT).with_name('SignIn.PackedNative.props')) + metadata_by_name = {Path(r.get('Include').split(')')[-1]).name: r + for r in props.iter('NativeReference')} + for ref in references: + framework = ref.get('Name') + expected_ref = metadata_by_name[framework] + for field in ('Kind', 'ForceLoad', 'SmartLink', 'Frameworks', 'WeakFrameworks', 'LinkerFlags'): + actual = ' '.join((ref.findtext(field) or '').split()) + expected_value = ' '.join((expected_ref.findtext(field) or '').split()) + if field in ('ForceLoad', 'SmartLink'): + actual, expected_value = actual.lower(), expected_value.lower() + release.require(actual == expected_value, f'Sign-In native metadata differs: {framework}/{field}') + libs = plistlib.loads(native.read(framework + '/Info.plist'))['AvailableLibraries'] + release.require(len(libs) == len(variants) and + {lib.get('SupportedPlatformVariant', '') for lib in libs} == variants, + f'Unexpected {platform} slices: {framework}') + for lib in libs: + variant = lib.get('SupportedPlatformVariant', '') + architectures = {'arm64', 'x86_64'} if variant else {'arm64'} + release.require(lib['SupportedPlatform'] == 'ios' and set(lib['SupportedArchitectures']) == architectures, + f'Unexpected {platform} architectures: {framework}') + binary = '/'.join((framework, lib['LibraryIdentifier'], lib['LibraryPath'], Path(framework).stem)) + release.require(static_archive(native.read(binary)), f'Expected static archive: {binary}') + for platform in ('ios', 'simulator', 'maccatalyst'): + for bundle in BUNDLES: + release.require(any(n.startswith(f'build/native-resources/{platform}/{bundle}/') and not n.endswith('/') + for n in info['files']), f'Missing Sign-In resource bundle: {platform}/{bundle}') + for folder, source in [('build', 'SignIn.targets'), ('buildTransitive', 'SignIn.buildTransitive.targets')]: + release.require(archive.read(f'{folder}/{package["id"]}.targets') == (ROOT / Path(PROJECT).with_name(source)).read_bytes(), + 'Stale Sign-In package targets') + provenance = json.loads(archive.read('native-provenance/NATIVE-BUILD.json')) + pins = release.read(ROOT / 'tools/SignInNative/Package.resolved')['pins'] + release.require(provenance['source_pins'] == pins and set(provenance['owned_frameworks']) == FRAMEWORKS, + 'Wrong Sign-In source provenance') + for repository in ('GoogleSignIn-iOS', 'AppAuth-iOS', 'GTMAppAuth'): + release.require(any(n.startswith(f'native-notices/{repository}-LICENSE') for n in info['files']), + 'Missing upstream Sign-In license: ' + repository) + + +def published_fixtures(release, folder): + manifest = release.read(ROOT / 'source/Firebase/native-assets.json') + tag = 'firebase-v' + manifest['releaseVersion'] + previous = release.github('releases/tags/' + tag) + release.require(not previous['draft'] and not previous['prerelease'], f'{tag} must be released first') + asset = next((a for a in previous['assets'] if a['name'] == 'manifest.json'), None) + release.require(asset is not None, 'Firebase 13 release is missing its sealed manifest') + sealed = json.loads(release.request(asset['browser_download_url'])) + release.require(sealed.get('channel', 'firebase') == 'firebase' and sealed['releaseVersion'] == manifest['releaseVersion'] and sealed.get('validation') + and all(v == 'passed' for v in sealed['validation'].values()), 'Firebase release evidence is incomplete') + versions = {p['id']: p for p in sealed['packages']} + registries = release.feeds() + from native import inspect_package + for p in manifest['packages']: + package = versions.get(p['package']) + release.require(package and release.version(package['version']) == release.version(p['version']), + 'Firebase release package version differs: ' + p['package']) + payloads = [f.fetch(package) for f in registries] + release.require(all(data is not None and release.payload_hash(data) == package['payloadHash'] for data in payloads), + 'Both feeds must contain the sealed Firebase package: ' + p['package']) + path = folder / f"{p['package']}.{release.normalized(p['version'])}.nupkg" + path.write_bytes(payloads[0]) + inspect_package(path, p) + + +def produce(release, plan, feed, closure): + feed.mkdir(parents=True, exist_ok=True) + closure.mkdir(parents=True, exist_ok=True) + if plan['eligible']: + published_fixtures(release, feed) + else: + subprocess.run([sys.executable, 'tools/firebase13/native.py', 'nuget'], cwd=ROOT, check=True) + subprocess.run([sys.executable, 'tools/signin-native/build.py'], cwd=ROOT, check=True) + project = ROOT / PROJECT + subprocess.run(['dotnet', 'restore', str(project), '--source', str(feed), '--source', + 'https://api.nuget.org/v3/index.json'], cwd=ROOT, check=True) + subprocess.run(['dotnet', 'pack', str(project), '--no-restore', '-c', 'Release', '-o', str(feed), + '-p:RepositoryCommit=' + plan['sourceCommit']], cwd=ROOT, check=True) + for p in plan['packages']: + name = f"{p['id']}.{release.normalized(p['version'])}.nupkg" + shutil.copyfile(feed / name, closure / name) + + +def validate_consumer(release, result, manifest, lane): + release.require(result.get('sourceCommit') == manifest['sourceCommit'] == os.environ.get('BUILD_COMMIT'), + 'Sign-In evidence has the wrong source commit') + release.require(result.get('Lane') == lane, 'Wrong Sign-In validation lane') + expected = {p['id']: p['sha256'] for p in manifest['packages']} + hashes = result.get('packageHashes', {}) + release.require(all(hashes.get(k) == v for k, v in expected.items()), 'Consumer evidence has wrong Sign-In package bytes') + fixtures = {} + if lane == 'combined': + versions = release.fixture_packages(integration_metadata(release)) + for package_id, version in versions.items(): + name = f"{package_id}.{release.normalized(version)}.nupkg" + fixtures[package_id] = release.digest((ROOT / 'output/firebase13' / name).read_bytes()) + release.require(hashes == {**expected, **fixtures}, 'Wrong Firebase integration fixture bytes') + checks = set(result.get('Checks', [])) + builds = {f'{platform}-{shape}-{rid}' + for platform, rids in [('ios', ('iossimulator-arm64', 'ios-arm64')), + ('maccatalyst', ('maccatalyst-arm64',))] + for shape in ('direct', 'transitive') for rid in rids} + restores = {f'restore-{platform}-{shape}' for platform in ('ios', 'maccatalyst') + for shape in ('direct', 'transitive')} + release.require(restores | builds | {b + '-offline-rebuild' for b in builds} <= checks, + 'Incomplete static Sign-In platform matrix') + suffix = ' firebase13=FIRAuth,FIRAnalytics,FIRFirestore' if lane == 'combined' else '' + probes = [re.fullmatch(r'PASS classes=8 selectors=(\d+) claims=auth_time,amr wrapper=wrapperIdentifier' + suffix, c) + for c in checks] + release.require(any(p and int(p[1]) >= 73 for p in probes), 'Missing Sign-In runtime and selector evidence') diff --git a/scripts/signin-release/consumers.py b/scripts/signin-release/consumers.py index ffd062f6..ee06afbe 100644 --- a/scripts/signin-release/consumers.py +++ b/scripts/signin-release/consumers.py @@ -136,7 +136,7 @@ class Program { static void Main(string[] args) => UIApplication.Main(args, null """ -def probe_source(combined, transitive, package_version): +def probe_source(combined, transitive, package_version, firebase_major=12): amr = release.version(package_version) >= release.version('9.2.0') wrapper = release.version(package_version) >= release.version('10.0.0') claims_probe = AMR_PROBE if amr else 'using var claims = new NSSet(claim, essentialClaim);' @@ -148,7 +148,10 @@ def probe_source(combined, transitive, package_version): if (objc_getClass(n) == IntPtr.Zero) throw new Exception("Missing Firebase class " + n); if (!Firebase.Core.App.FirebaseVersion.StartsWith("12.", StringComparison.Ordinal)) throw new Exception("Expected Firebase 12 runtime");''' - marker = ' firebase12=FIRAuth,FIRAnalytics,FIRFirestore' if combined else '' + if combined and firebase_major != 12: + extra = extra.replace('StartsWith("12."', f'StartsWith("{firebase_major}."') + extra = extra.replace('Expected Firebase 12 runtime', f'Expected Firebase {firebase_major} runtime') + marker = f' firebase{firebase_major}=FIRAuth,FIRAnalytics,FIRFirestore' if combined else '' return (PROBE.replace('CLAIMS_PROBE', claims_probe) .replace('SELECTOR_FLOOR', '73' if wrapper else '71' if amr else '67') .replace('CLAIM_NAMES', ('auth_time,amr' if amr else 'auth_time') + (' wrapper=wrapperIdentifier' if wrapper else '')) diff --git a/source/Google/SignIn/SignIn.PackedNative.props b/source/Google/SignIn/SignIn.PackedNative.props new file mode 100644 index 00000000..18a95a4f --- /dev/null +++ b/source/Google/SignIn/SignIn.PackedNative.props @@ -0,0 +1,38 @@ + + + + $(MSBuildThisFileDirectory)..\..\..\externals\signin-native\ + $(SignInNativeAssetsRoot)ios\ + $(SignInNativeAssetsRoot)maccatalyst\ + true + + + + FrameworkTrueTrue + TrueTrue + -ObjC -lc++ -lsqlite3 + CoreGraphics CoreText Foundation LocalAuthentication Security UIKitSafariServices + + + FrameworkTrueTrue + TrueTrue + -ObjC -lc++ -lsqlite3 + SafariServicesAuthenticationServices + + + FrameworkTrueTrue + TrueTrue + -ObjC -lc++ -lsqlite3 + Foundation Security + + + FrameworkTrueTrue + TrueTrue + -ObjC -lc++ -lsqlite3 + Security SystemConfiguration SafariServices + + + + + + diff --git a/source/Google/SignIn/SignIn.buildTransitive.targets b/source/Google/SignIn/SignIn.buildTransitive.targets new file mode 100644 index 00000000..910eed1d --- /dev/null +++ b/source/Google/SignIn/SignIn.buildTransitive.targets @@ -0,0 +1,3 @@ + + + diff --git a/source/Google/SignIn/SignIn.csproj b/source/Google/SignIn/SignIn.csproj index 3b9f4bd8..1a74306f 100644 --- a/source/Google/SignIn/SignIn.csproj +++ b/source/Google/SignIn/SignIn.csproj @@ -8,8 +8,8 @@ 15.0 Google.SignIn Google.SignIn - 10.1.0.0 - 10.1.0.0 + 10.1.0.1 + 10.1.0.1 Resources true @@ -26,7 +26,7 @@ License.md NUGET_README.md true - 10.1.0.0 + 10.1.0.1 @@ -35,34 +35,13 @@ + + - - - Framework - True - True - SafariServices - AuthenticationServices - - - Framework - True - True - Security SystemConfiguration SafariServices - - - Framework - True - True - -ObjC -lsqlite3 - CoreGraphics CoreText Foundation LocalAuthentication Security UIKit - SafariServices - - @@ -70,9 +49,10 @@ - - - - + + + + + diff --git a/source/Google/SignIn/SignIn.targets b/source/Google/SignIn/SignIn.targets new file mode 100644 index 00000000..7314436b --- /dev/null +++ b/source/Google/SignIn/SignIn.targets @@ -0,0 +1,52 @@ + + + <_AdamESignInPackedRoot>$(MSBuildThisFileDirectory) + + + <_AdamESignInPlatform>ios + <_AdamESignInPlatform Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('iossimulator-'))">simulator + <_AdamESignInPlatform Condition="'$(TargetPlatformIdentifier)' == 'maccatalyst'">maccatalyst + + + + <_AdamESignInBundle0 Remove="@(_AdamESignInBundle0)" /> + <_AdamESignInBundle0 Include="$(_AdamESignInPackedRoot)native-resources/$(_AdamESignInPlatform)/GoogleSignIn_GoogleSignIn.bundle/**/*" /> + + + GoogleSignIn_GoogleSignIn.bundle/%(_AdamESignInBundle0.RecursiveDir)%(_AdamESignInBundle0.Filename)%(_AdamESignInBundle0.Extension) + False + + + + + <_AdamESignInBundle1 Remove="@(_AdamESignInBundle1)" /> + <_AdamESignInBundle1 Include="$(_AdamESignInPackedRoot)native-resources/$(_AdamESignInPlatform)/AppAuth_AppAuth.bundle/**/*" /> + + + AppAuth_AppAuth.bundle/%(_AdamESignInBundle1.RecursiveDir)%(_AdamESignInBundle1.Filename)%(_AdamESignInBundle1.Extension) + False + + + + + <_AdamESignInBundle2 Remove="@(_AdamESignInBundle2)" /> + <_AdamESignInBundle2 Include="$(_AdamESignInPackedRoot)native-resources/$(_AdamESignInPlatform)/AppAuth_AppAuthCore.bundle/**/*" /> + + + AppAuth_AppAuthCore.bundle/%(_AdamESignInBundle2.RecursiveDir)%(_AdamESignInBundle2.Filename)%(_AdamESignInBundle2.Extension) + False + + + + + <_AdamESignInBundle3 Remove="@(_AdamESignInBundle3)" /> + <_AdamESignInBundle3 Include="$(_AdamESignInPackedRoot)native-resources/$(_AdamESignInPlatform)/GTMAppAuth_GTMAppAuth.bundle/**/*" /> + + + GTMAppAuth_GTMAppAuth.bundle/%(_AdamESignInBundle3.RecursiveDir)%(_AdamESignInBundle3.Filename)%(_AdamESignInBundle3.Extension) + False + + + + diff --git a/tests/firebase-release/test_signin13.py b/tests/firebase-release/test_signin13.py new file mode 100644 index 00000000..5fef7727 --- /dev/null +++ b/tests/firebase-release/test_signin13.py @@ -0,0 +1,81 @@ +import argparse +import importlib.util +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('release', ROOT / 'scripts/firebase-release/release.py') +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) +adapter = release.signin13() + + +class SignIn13Tests(unittest.TestCase): + def setUp(self): + release.configure_channel('signin') + self.addCleanup(release.configure_channel, 'firebase') + + def test_static_revision_requires_the_published_1010_predecessor(self): + with self.assertRaisesRegex(ValueError, 'expected 10.1.0'): + release.require_signin_predecessor('10.1.0.1', {'releaseVersion': '10.0.0'}) + release.require_signin_predecessor('10.1.0.1', {'releaseVersion': '10.1.0'}) + + def test_missing_support_from_one_feed_blocks_all_writes(self): + package = {'id': 'AdamE.Google.iOS.AppCheckCore', 'version': '12.0.0', 'payloadHash': 'verified'} + feeds = [Mock(name='nuget'), Mock(name='github')] + feeds[0].name, feeds[1].name = 'nuget', 'github' + feeds[0].fetch.return_value = b'available' + feeds[1].fetch.return_value = None + with patch.object(release, 'payload_hash', return_value='verified'), patch.object(release.subprocess, 'run') as push: + with self.assertRaisesRegex(ValueError, 'Required dependency missing: github'): + release.publish_packages(Path('.'), [package], feeds, reuse_only=release.SIGNIN_SHARED_IDS) + push.assert_not_called() + + def test_static_pr_plan_stays_ineligible_even_with_version_set(self): + metadata = [ + {'id': package, 'version': '10.1.0.1', 'firebase': False} + for package in sorted(release.SIGNIN_IDS) + ] + config = {'releaseVersion': '10.1.0.1', + 'nativeBuild': {'runner': 'xcode-27.1', 'xcode': '27.1', 'delivery': 'firebase13-static'}} + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + release.write(directory / release.CONFIG, config) + release.write(directory / release.BASELINE, {'packages': []}) + release.write(directory / 'metadata.json', metadata) + with patch.object(release, 'ROOT', directory), patch.object(release, 'snapshot', return_value=metadata), \ + patch.object(release, 'run', return_value='abc'), patch.dict(os.environ, GITHUB_EVENT_NAME='pull_request'): + release.plan(argparse.Namespace(metadata=directory/'metadata.json', online=False, manual=True, + base=None, output=directory/'plan.json')) + plan = release.read(directory/'plan.json') + self.assertTrue(plan['relevant']) + self.assertFalse(plan['eligible']) + + def test_static_planning_classifies_firebase13_fixture_changes(self): + integration = adapter.integration_metadata(release) + fixtures = release.fixture_packages(integration) + self.assertEqual(release.normalized(fixtures['AdamE.Firebase.iOS.CloudFirestore']), '13.1.1') + path = 'source/Firebase/CloudFirestore/ApiDefinition.cs' + self.assertEqual(release.validation_reasons([path], [], 'signin', integration), [path]) + + def test_consumer_evidence_requires_exact_hashes_matrix_and_runtime(self): + package = {'id': release.SIGNIN_ROOT_ID, 'sha256': 'sealed'} + manifest = {'sourceCommit': 'abc', 'packages': [package]} + builds = release.signin_checks('arm64') - {'ios-direct-iossimulator-arm64-rebuild'} + checks = builds | {c + '-offline-rebuild' for c in builds if not c.startswith('restore-')} + checks.add('PASS classes=8 selectors=75 claims=auth_time,amr wrapper=wrapperIdentifier') + valid = {'Lane': 'consumers', 'sourceCommit': 'abc', 'Checks': sorted(checks), + 'packageHashes': {package['id']: 'sealed'}} + with patch.dict(os.environ, BUILD_COMMIT='abc'): + adapter.validate_consumer(release, valid, manifest, 'consumers') + for field, value, error in [ + ('sourceCommit', 'def', 'wrong source commit'), + ('packageHashes', {package['id']: 'different'}, 'wrong Sign-In package bytes'), + ('Checks', sorted(checks - {'ios-transitive-ios-arm64-offline-rebuild'}), 'Incomplete'), + ('Checks', sorted(c for c in checks if not c.startswith('PASS ')), 'runtime and selector'), + ]: + with self.subTest(field=field), self.assertRaisesRegex(ValueError, error): + adapter.validate_consumer(release, {**valid, field: value}, manifest, 'consumers') diff --git a/tools/SignInNative/Package.resolved b/tools/SignInNative/Package.resolved new file mode 100644 index 00000000..57823859 --- /dev/null +++ b/tools/SignInNative/Package.resolved @@ -0,0 +1,78 @@ +{ + "originHash" : "fc54af3aa74eb12535c0b5356eb88ae954cf0bba3da99170e6e3dd3eeb587017", + "pins" : [ + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "c7eb17e5545f36e43dc50e70da09c2e999714e16", + "version" : "12.0.0" + } + }, + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS.git", + "state" : { + "revision" : "a972daac82d449d58ab119e91c68153e29ddac33", + "version" : "3.0.0" + } + }, + { + "identity" : "googlesignin-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleSignIn-iOS.git", + "state" : { + "revision" : "bfaebbc04a4d13ed7a89b0fc1fb8ad66b362cc1c", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "5459ee5603def3db0fe14ca522185f42c48d978e", + "version" : "8.1.5" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", + "version" : "5.3.1" + } + }, + { + "identity" : "gtmappauth", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GTMAppAuth.git", + "state" : { + "revision" : "57094cd46bc8700d3a887e2a429f42b6e8ea3f0b", + "version" : "6.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + } + ], + "version" : 3 +} diff --git a/tools/SignInNative/Package.swift b/tools/SignInNative/Package.swift new file mode 100644 index 00000000..6e5cc28c --- /dev/null +++ b/tools/SignInNative/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:6.0 +import PackageDescription + +// Producer-only dependency graph. No SwiftPM files or build tasks are shipped to consumers. +let package = Package( + name: "AdamESignInNative", + platforms: [.iOS(.v15), .macCatalyst(.v15)], + products: [.library(name: "SignInClosure", type: .static, targets: ["SignInClosure"])], + dependencies: [ + .package(url: "https://github.com/google/GoogleSignIn-iOS.git", exact: "10.1.0"), + .package(url: "https://github.com/openid/AppAuth-iOS.git", exact: "3.0.0"), + .package(url: "https://github.com/google/GTMAppAuth.git", exact: "6.0.0"), + .package(url: "https://github.com/google/app-check.git", exact: "12.0.0"), + .package(url: "https://github.com/google/gtm-session-fetcher.git", exact: "5.3.1"), + .package(url: "https://github.com/google/GoogleUtilities.git", exact: "8.1.5"), + .package(url: "https://github.com/google/promises.git", exact: "2.4.1"), + ], + targets: [.target(name: "SignInClosure", dependencies: [ + .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), + .product(name: "AppAuth", package: "AppAuth-iOS"), + .product(name: "AppAuthCore", package: "AppAuth-iOS"), + .product(name: "GTMAppAuth", package: "GTMAppAuth"), + ])] +) diff --git a/tools/SignInNative/Sources/SignInClosure/SignInClosure.c b/tools/SignInNative/Sources/SignInClosure/SignInClosure.c new file mode 100644 index 00000000..2d0826a0 --- /dev/null +++ b/tools/SignInNative/Sources/SignInClosure/SignInClosure.c @@ -0,0 +1,2 @@ +// Producer-only build graph anchor. Individual SDK target objects are normalized separately. +void AdamESignInClosureAnchor(void) {} diff --git a/tools/SignInNative/Sources/SignInClosure/include/SignInClosure.h b/tools/SignInNative/Sources/SignInClosure/include/SignInClosure.h new file mode 100644 index 00000000..ac28a9c4 --- /dev/null +++ b/tools/SignInNative/Sources/SignInClosure/include/SignInClosure.h @@ -0,0 +1 @@ +// Producer-only build graph anchor. Not part of the packaged native SDK. diff --git a/tools/firebase13/consumers.py b/tools/firebase13/consumers.py index e38d49fa..d32167b2 100644 --- a/tools/firebase13/consumers.py +++ b/tools/firebase13/consumers.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Exercise direct/transitive Firebase native acquisition and warm-cache offline builds.""" import argparse +import importlib.util import json import os from pathlib import Path @@ -39,14 +40,34 @@ def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--resume',type=Path) parser.add_argument('--platform',choices=['ios','maccatalyst']) - parser.add_argument('--scope',choices=['core','combined','all'],default='combined') + parser.add_argument('--scope',choices=['core','combined','all','signin'],default='combined') parser.add_argument('--without-ad-id',action='store_true') parser.add_argument('--quick',action='store_true',help='Direct iOS simulator only; the default includes device/Catalyst/transitive shapes') parser.add_argument('--cache',type=Path,help='Reuse an existing XBD cache instead of testing a fresh acquisition') + parser.add_argument('--with-signin', action='store_true', help='Include the Firebase 13 compatible Sign-In binding revision') + parser.add_argument('--signin-candidate', type=Path) args=parser.parse_args() + if args.with_signin and args.scope != 'combined': + parser.error('--with-signin requires --scope combined') m=json.loads(MANIFEST.read_text());feed=ROOT/'output/firebase13' label=args.scope+('-without-ad-id' if args.without_ad_id else '') - roots={'core':['Core'],'combined':['Analytics','Auth','CloudFirestore'],'all':[p['id'].split('.',1)[1] for p in m['packages'] if p['id'].startswith('Firebase.')]}[args.scope] + signin = args.with_signin or args.scope == 'signin' + if args.with_signin: + label += '-signin' + if args.signin_candidate and (not signin or args.quick or args.platform): + parser.error('Sealed Sign-In evidence requires the full platform matrix') + checks = [] + if args.signin_candidate: + lane = 'combined' if args.with_signin else 'consumers' + evidence = ROOT/'output/signin-results'/f'{lane}.json' + evidence.unlink(missing_ok=True) + if signin: + spec = importlib.util.spec_from_file_location('signin_consumers', ROOT/'scripts/signin-release/consumers.py') + signin_consumers = importlib.util.module_from_spec(spec) + spec.loader.exec_module(signin_consumers) + signin_package = 'AdamE.Google.iOS.SignIn' + signin_version = '10.1.0.1' + roots={'signin':[],'core':['Core'],'combined':['Analytics','Auth','CloudFirestore'],'all':[p['id'].split('.',1)[1] for p in m['packages'] if p['id'].startswith('Firebase.')]}[args.scope] work=(args.resume or Path(tempfile.mkdtemp(prefix='firebase13-consumer-'))).resolve();print('WORKSPACE',work,flush=True) results=ROOT/'output/firebase13-results';results.mkdir(parents=True,exist_ok=True) (results/(label+'-workspace.txt')).write_text(str(work)) @@ -62,22 +83,26 @@ def run(name,cmd,blocked=False): with (results/(label+'-'+name+'.log')).open('w') as output: p=subprocess.run(cmd,cwd=work,env=offline if blocked else env,stdout=output,stderr=subprocess.STDOUT) if p.returncode:raise RuntimeError((results/(label+'-'+name+'.log')).read_text()[-10000:]) + checks.append(name) projects=[] for platform in ([args.platform] if args.platform else ['ios'] if args.quick else ['ios','maccatalyst']): for transitive in ([False] if args.quick else [False,True]): project=work/(platform+('-transitive' if transitive else '-direct'));project.mkdir(exist_ok=True) versions={p['id'].split('.',1)[1]:p['version'] for p in m['packages'] if p['id'].startswith('Firebase.')} refs=''.join(f'' for name in roots) + if signin: + refs += f'' if transitive: lib=project/'Library';lib.mkdir(exist_ok=True) (lib/'Library.csproj').write_text(f'net10.0-{platform}{refs}') - (lib/'Class.cs').write_text('public class BindingConsumer { public static string Version => Firebase.Core.App.FirebaseVersion; }') + (lib/'Class.cs').write_text('public static class BindingProbe { public static string Describe() => typeof(Google.SignIn.SignIn).FullName; }' + if signin else 'public class BindingConsumer { public static string Version => Firebase.Core.App.FirebaseVersion; }') refs='' minimum_os='17.0' if platform=='maccatalyst' else '15.0' (project/'Probe.csproj').write_text(f'net10.0-{platform}{"iossimulator-arm64;ios-arm64" if platform=="ios" else "maccatalyst-arm64"}Exe{minimum_os}falsefalseNone{refs}') classes=['FIRApp']+([] if args.scope=='core' else ['FIRAnalytics','FIRAuth','FIRFirestore','__FIRExprBridge','__FIRSortStageBridge']) extra='' - if args.scope!='core': + if args.scope not in ('core','signin'): extra='''var sortType=typeof(Firebase.CloudFirestore.SortStageBridge); if(sortType==null) throw new Exception("Missing managed Firestore bridge"); using var field=new Firebase.CloudFirestore.FieldBridge("field"); if(field.FieldName!="field") throw new Exception("Firestore bridge roundtrip failed"); @@ -110,18 +135,32 @@ def run(name,cmd,blocked=False): Action uploadApi=task=>{task.Enqueue();task.Pause();task.Cancel();task.Resume();}; GC.KeepAlive(downloadApi); GC.KeepAlive(uploadApi); """ - (project/'Main.cs').write_text(PROBE.replace('EXPECTED_FIREBASE_VERSION',m['releaseVersion']).replace('CLASS_NAMES',','.join('"'+n+'"' for n in classes)).replace('EXTRA_PROBE',extra)) + source = (signin_consumers.probe_source(args.with_signin, transitive, signin_version, firebase_major=13) + if signin else PROBE.replace('EXPECTED_FIREBASE_VERSION',m['releaseVersion']) + .replace('CLASS_NAMES',','.join('"'+n+'"' for n in classes)).replace('EXTRA_PROBE',extra)) + (project/'Main.cs').write_text(source) bundle='com.googleapisforioscomponents.firebase13.'+work.name.split('-')[-1]+'.'+platform+('.transitive' if transitive else '.direct') (project/'Info.plist').write_text(f'CFBundleIdentifier{bundle}CFBundleShortVersionString1.0CFBundleVersion1UIApplicationSceneManifestUIApplicationSupportsMultipleScenesUISceneConfigurationsUIWindowSceneSessionRoleApplicationUISceneConfigurationNameDefault ConfigurationUISceneDelegateClassNameSceneDelegate') projects.append((project,platform,bundle)) before=snapshot(cache) for project,platform,bundle in projects:run('restore-'+project.name,['dotnet','restore',str(project/'Probe.csproj'),'--force','--configfile',str(work/'NuGet.config')]) assert snapshot(cache)==before,'Restore acquired native SDK files' + package_hashes = {} for p in m['packages']: version=p['version'].removesuffix('.0') restored=work/'nuget'/p['package'].lower()/version/(p['package'].lower()+'.'+version+'.nupkg') if restored.exists(): assert digest(restored)==digest(feed/(p['package']+'.'+version+'.nupkg')), 'Restored package differs from current candidate; rerun in a fresh workspace: '+p['package'] + package_hashes[p['package']] = digest(restored) + if signin: + restored = work/'nuget'/signin_package.lower()/signin_version/(signin_package.lower()+'.'+signin_version+'.nupkg') + assert digest(restored) == digest(feed/(signin_package+'.'+signin_version+'.nupkg')), 'Restored Sign-In package differs from candidate' + package_hashes[signin_package] = digest(restored) + if args.signin_candidate: + candidate = json.loads((args.signin_candidate/'manifest.json').read_text()) + expected = {p['id']: p['sha256'] for p in candidate['packages']} + assert all(package_hashes.get(k) == v for k, v in expected.items()), 'Consumer differs from staged Sign-In candidate' + assert candidate['sourceCommit'] == subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip() restored_packages=snapshot(work/'nuget') first=True;simulator=None for project,platform,bundle in projects: @@ -143,7 +182,14 @@ def run(name,cmd,blocked=False): assert any(p.exists() and digest(p)==digest(Path(source)) for p in candidates), 'Missing/altered privacy resource: '+logical bundles=[p.name for p in app.rglob('*.bundle')] assert len(bundles)==len(set(bundles)),'Duplicate logical bundles' - if args.scope!='core':assert ('GoogleAdsOnDeviceConversion.xcframework' in '\n'.join(assets)) == (platform=='ios' and not args.without_ad_id) + if signin: + expected = {'GoogleSignIn_GoogleSignIn.bundle', 'AppAuth_AppAuth.bundle', + 'AppAuth_AppAuthCore.bundle', 'GTMAppAuth_GTMAppAuth.bundle'} + assert expected <= set(bundles), 'Missing packed Sign-In resource bundle(s): ' + str(expected - set(bundles)) + assert not any(p.is_dir() and p.name in {'GoogleSignIn.framework', 'AppAuth.framework', + 'AppAuthCore.framework', 'GTMAppAuth.framework', 'AppCheckCore.framework', + 'GTMSessionFetcher.framework'} for p in app.rglob('*.framework')), 'Unexpected dynamic framework' + if args.scope not in ('core','signin'):assert ('GoogleAdsOnDeviceConversion.xcframework' in '\n'.join(assets)) == (platform=='ios' and not args.without_ad_id) if args.without_ad_id:assert 'GoogleAppMeasurementIdentitySupport.xcframework' not in '\n'.join(assets) if first:simulator=(app,bundle) first=False @@ -162,12 +208,22 @@ def run(name,cmd,blocked=False): subprocess.run(['xcrun','simctl','install',device,str(app)],check=True) subprocess.run(['xcrun','simctl','launch',device,bundle],check=True) container=Path(subprocess.check_output(['xcrun','simctl','get_app_container',device,bundle,'data']).decode().strip()) - result=container/'Documents/firebase13-result.txt' + result=container/'Documents'/('signin-result.txt' if signin else 'firebase13-result.txt') for _ in range(30): if result.exists():break time.sleep(1) text=result.read_text() if result.exists() else 'timeout' - assert text.startswith('PASS Firebase 13.'),text + assert text.startswith('PASS classes=' if signin else 'PASS Firebase 13.'),text + if args.signin_candidate: + lane = 'combined' if args.with_signin else 'consumers' + evidence = ROOT/'output/signin-results'/f'{lane}.json' + evidence.parent.mkdir(parents=True, exist_ok=True) + checks.append(text) + evidence.write_text(json.dumps({ + 'Success': True, 'Cases': [{'Success': True, 'Name': check} for check in checks], + 'Checks': checks, 'Lane': lane, 'sourceCommit': candidate['sourceCommit'], + 'packageHashes': package_hashes, + }, indent=2)) (results/(label+'-result.json')).write_text(json.dumps({'success':True,'runtime':text,'workspace':str(work),'sourceCommit':subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT).decode().strip(),'scope':args.scope,'quick':args.quick},indent=2)) print(text,flush=True) diff --git a/tools/legacy/producer.py b/tools/legacy/producer.py index d8a875e9..4d898ce3 100644 --- a/tools/legacy/producer.py +++ b/tools/legacy/producer.py @@ -101,11 +101,18 @@ def norm(v):return tuple(int(n) for n in v.split('.'))+(0,)*(4-len(v.split('.')) return all(p['id'] in versions and norm(p['version'])==norm(versions[p['id']]) for p in metadata) +def static_signin_enabled(root): + config = root / 'eng/signin-release.json' + return config.exists() and json.loads(config.read_text())['nativeBuild'].get('delivery') == 'firebase13-static' + + def verify_nonfirebase_inputs(root, stage, data, manifest): """Never silently ignore a later product edit while using a frozen baseline.""" root=root.resolve();stage=stage.resolve() migrated_ids={p['id'] for p in manifest['packages']} migrated={p['key'] for p in data['artifacts'] if p['artifactId'] in migrated_ids} + if static_signin_enabled(root): + migrated.add('Google.SignIn') tracked=set(subprocess.check_output(['git','ls-files'],cwd=root,text=True).splitlines()) for p in data['artifacts']: if p['key'] in migrated:continue @@ -212,6 +219,9 @@ def main(): args=parser.parse_args();data=lock();manifest=json.loads((ROOT/'source/Firebase/native-assets.json').read_text()) selection=plan(data,manifest,args.names,args.mode) if args.command=='plan':print(json.dumps(selection,indent=2));return + if static_signin_enabled(ROOT) and (args.command=='metadata' and args.channel=='signin' + or args.command=='build' and 'Google.SignIn' in selection['legacy']): + raise ValueError('Use the Sign-In release channel signin13-metadata or signin13-produce command for static Sign-In.') if args.command=='metadata' or selection['legacy']: with source_tree() as source: verify_nonfirebase_inputs(ROOT,source,data,manifest) diff --git a/tools/signin-native/build.py b/tools/signin-native/build.py new file mode 100644 index 00000000..92ccd57f --- /dev/null +++ b/tools/signin-native/build.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Producer-only pinned Sign-In source build. Consumers receive ordinary binding resources. + +Only the four owned SDK targets are archived. Shared Firebase support targets built by SwiftPM +are deliberately excluded, preventing a second AppCheckCore/GTMSessionFetcher/Promises owner. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import plistlib +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +PACKAGE = ROOT / 'tools/SignInNative' +DESTINATION = ROOT / 'externals/signin-native' +TARGETS = {'GoogleSignIn': ('GoogleSignIn-iOS', '10.1.0'), 'AppAuth': ('AppAuth-iOS', '3.0.0'), + 'AppAuthCore': ('AppAuth-iOS', '3.0.0'), 'GTMAppAuth': ('GTMAppAuth', '6.0.0')} +PLATFORMS = {'ios': ('generic/platform=iOS', 'Release-iphoneos', ['arm64']), + 'simulator': ('generic/platform=iOS Simulator', 'Release-iphonesimulator', ['arm64', 'x86_64']), + 'maccatalyst': ('generic/platform=macOS,variant=Mac Catalyst', 'Release-maccatalyst', ['arm64', 'x86_64'])} +BUNDLES = ['GoogleSignIn_GoogleSignIn.bundle', 'AppAuth_AppAuth.bundle', + 'AppAuth_AppAuthCore.bundle', 'GTMAppAuth_GTMAppAuth.bundle'] + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def snapshot(root): + return {p.relative_to(root).as_posix(): sha(p) for p in sorted(root.rglob('*')) + if p.is_file() and p.name != 'NATIVE-BUILD.json'} + + +def run(args, **kwargs): + subprocess.run([str(a) for a in args], check=True, **kwargs) + + +def validate_contract(): + pins = json.loads((PACKAGE / 'Package.resolved').read_text())['pins'] + expected = {'googlesignin-ios': '10.1.0', 'appauth-ios': '3.0.0', 'gtmappauth': '6.0.0', + 'app-check': '12.0.0', 'gtm-session-fetcher': '5.3.1', 'googleutilities': '8.1.5', 'promises': '2.4.1', + 'interop-ios-for-google-sdks': '101.0.0'} + actual = {p['identity']: p['state'].get('version') for p in pins} + if actual != expected: + raise ValueError('Sign-In source lock does not match the reviewed Firebase 13 closure') + origins = {'appauth-ios': 'openid/AppAuth-iOS', 'googlesignin-ios': 'google/GoogleSignIn-iOS', + 'gtmappauth': 'google/GTMAppAuth', 'app-check': 'google/app-check', + 'gtm-session-fetcher': 'google/gtm-session-fetcher', 'googleutilities': 'google/GoogleUtilities', + 'promises': 'google/promises', 'interop-ios-for-google-sdks': 'google/interop-ios-for-google-sdks'} + for pin in pins: + if pin['location'] != 'https://github.com/' + origins[pin['identity']] + '.git' or len(pin['state']['revision']) != 40: + raise ValueError('Unpinned or non-official Sign-In source dependency') + project = ET.parse(ROOT / 'source/Google/SignIn/SignIn.csproj') + manifest = json.loads((ROOT / 'source/Firebase/native-assets.json').read_text()) + shared_names = {'AppCheckCore', 'GTMSessionFetcher', 'GoogleUtilities', 'PromisesObjC'} + shared_versions = { + p['package']: f"[{p['version'].removesuffix('.0') if p['version'].count('.') == 3 else p['version']}]" + for p in manifest['packages'] if p['id'].rsplit('.', 1)[-1] in shared_names + } + references = {p.get('Include'): p.get('Version') for p in project.iter('PackageReference')} + if references != shared_versions or len(references) != len(shared_names): + raise ValueError('Sign-In NuGet dependencies must match the exact Firebase archive closure') + if project.findtext('.//PackageVersion') != '10.1.0.1': + raise ValueError('Expected the Firebase 13 binding revision 10.1.0.1') + props = ET.parse(ROOT / 'source/Google/SignIn/SignIn.PackedNative.props') + frameworks = {p.get('Include').split(')')[-1] for p in props.iter('NativeReference')} + if frameworks != {n + '.xcframework' for n in TARGETS}: + raise ValueError('Sign-In producer framework ownership mismatch') + targets = (ROOT / 'source/Google/SignIn/SignIn.targets').read_text() + if any(b not in targets for b in BUNDLES) or any(s in targets for s in ['XamarinBuildDownload', 'xcodebuild', 'Package.swift']): + raise ValueError('Sign-In consumer resource contract mismatch') + return pins + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--validate-only', action='store_true') + args = parser.parse_args() + pins = validate_contract() + if args.validate_only: + print('Validated pinned Sign-In 10.1 producer source graph without acquisition.') + return + xcode = subprocess.check_output(['xcodebuild', '-version']).decode().strip() + key = {'recipe': sha(Path(__file__)), 'manifest': sha(PACKAGE / 'Package.swift'), + 'lock': sha(PACKAGE / 'Package.resolved'), 'xcode': xcode} + marker = DESTINATION / 'NATIVE-BUILD.json' + if marker.exists(): + previous = json.loads(marker.read_text()) + if previous['inputs'] == key and previous['files'] == snapshot(DESTINATION): + print('Reusing verified source-built Sign-In 10.1 native artifact.') + return + derived_root = ROOT / 'externals/signin10-build' + derived_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='.signin-native-', dir=ROOT / 'externals') as temp: + out = Path(temp) + slices = {name: [] for name in TARGETS} + for platform, (destination, product_directory, architectures) in PLATFORMS.items(): + derived = derived_root / platform + log = derived_root / (platform + '.log') + print('Building Sign-In producer closure for ' + platform, flush=True) + with log.open('w') as stream: + run(['xcodebuild', '-scheme', 'AdamESignInNative', '-destination', destination, + '-configuration', 'Release', '-derivedDataPath', derived, + '-clonedSourcePackagesDirPath', PACKAGE / '.build', + '-onlyUsePackageVersionsFromResolvedFile', 'CODE_SIGNING_ALLOWED=NO', 'ONLY_ACTIVE_ARCH=NO', + 'ARCHS=' + ' '.join(architectures), 'GCC_GENERATE_DEBUGGING_SYMBOLS=NO', + 'SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO', 'build'], cwd=PACKAGE, stdout=stream, stderr=subprocess.STDOUT) + for pin in pins: + checkout = PACKAGE / '.build/checkouts' / pin['identity'] + revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=checkout, text=True).strip() + changes = subprocess.check_output(['git', 'status', '--porcelain'], cwd=checkout, text=True) + if revision != pin['state']['revision'] or changes: + raise ValueError('Source checkout does not match the reviewed lock: ' + pin['identity']) + products = derived / 'Build/Products' / product_directory + for name, (repository, version) in TARGETS.items(): + framework = out / 'slices' / platform / (name + '.framework') + framework.mkdir(parents=True) + archives = [] + for architecture in architectures: + intermediates = derived / 'Build/Intermediates.noindex' + object_dirs = [ + directory for suffix in ('.build', '-t.build') + for directory in intermediates.glob(f'**/{name}{suffix}/Objects-normal/{architecture}') + ] + objects = sorted(object_dirs[0].glob('*.o')) if len(object_dirs) == 1 else [] + if not objects: + raise ValueError(f'Missing {name}/{architecture} target objects; see {log}') + archive = out / 'slices' / platform / (name + '-' + architecture + '.a') + run(['xcrun', 'libtool', '-static', '-o', archive, *sorted(objects)], + env=dict(os.environ, ZERO_AR_DATE='1')) + archives.append(archive) + run(['xcrun', 'lipo', '-create', *archives, '-output', framework / name]) + headers = framework / 'Headers'; headers.mkdir() + checkout = PACKAGE / '.build/checkouts' / repository + if name == 'GoogleSignIn': + sources = (checkout / 'GoogleSignIn/Sources/Public/GoogleSignIn').glob('*.h') + elif name in ('AppAuth', 'AppAuthCore'): + sources = (checkout / 'Sources' / name).rglob('*.h') + else: + sources = (header for suffix in ('.build', '-t.build') + for header in (derived / 'Build/Intermediates.noindex').glob( + f'**/GTMAppAuth{suffix}/**/GTMAppAuth-Swift.h')) + for header in sources: + shutil.copy2(header, headers / header.name) + info = {'CFBundleIdentifier': 'org.adame.native.' + name, 'CFBundleName': name, + 'CFBundleExecutable': name, 'CFBundlePackageType': 'FMWK', + 'CFBundleShortVersionString': version, 'CFBundleVersion': version, + 'MinimumOSVersion': '15.0'} + (framework / 'Info.plist').write_bytes(plistlib.dumps(info)) + slices[name].append(framework) + resource_root = out / 'resources' / platform; resource_root.mkdir(parents=True) + for bundle in BUNDLES: + source = products / bundle + if not source.is_dir(): + raise ValueError('Missing producer resource ' + str(source)) + shutil.copytree(source, resource_root / bundle, symlinks=True) + for name, frameworks in slices.items(): + xcframework = out / (name + '.xcframework') + command = ['xcodebuild', '-create-xcframework'] + for framework in frameworks: + command += ['-framework', framework] + run(command + ['-output', xcframework]) + info = plistlib.loads((xcframework / 'Info.plist').read_bytes()) + for platform in ['ios', 'maccatalyst']: + selected = [s for s in info['AvailableLibraries'] if + (s.get('SupportedPlatformVariant') == 'maccatalyst') == (platform == 'maccatalyst')] + wrapper = out / platform / xcframework.name; wrapper.mkdir(parents=True) + for library in selected: + shutil.copytree(xcframework / library['LibraryIdentifier'], wrapper / library['LibraryIdentifier'], symlinks=True) + (wrapper / 'Info.plist').write_bytes(plistlib.dumps(dict(info, AvailableLibraries=selected))) + notices = out / 'native-notices'; notices.mkdir() + for repository in sorted({r for r, v in TARGETS.values()}): + checkout = PACKAGE / '.build/checkouts' / repository + files = [p for p in checkout.iterdir() if p.is_file() and p.name.upper().startswith(('LICENSE', 'NOTICE', 'COPYING'))] + if not files: + raise ValueError('Missing upstream source license: ' + repository) + for file in files: + shutil.copy2(file, notices / (repository + '-' + file.name)) + shutil.rmtree(out / 'slices') + (out / 'NATIVE-BUILD.json').write_text(json.dumps({'inputs': key, 'source_pins': pins, + 'owned_frameworks': list(TARGETS), 'excluded_shared_targets': ['AppCheckCore', 'GTMSessionFetcherCore', 'GoogleUtilities', 'FBLPromises'], + 'files': snapshot(out)}, indent=2) + '\n') + # All inputs are locked and every generated native file is checksummed before packing. + # No producer files or build tools are needed on consumer machines. + archive = ROOT / 'externals/signin-native.zip' + run(['ditto', '-c', '-k', out, archive]) + if DESTINATION.exists(): + backup = out.parent / (out.name + '-old') + DESTINATION.rename(backup) + try: + shutil.copytree(out, DESTINATION, symlinks=True) + except Exception: + if DESTINATION.exists(): + shutil.rmtree(DESTINATION) + backup.rename(DESTINATION) + raise + shutil.rmtree(backup) + else: + shutil.copytree(out, DESTINATION, symlinks=True) + print('Produced source-built Sign-In native archive SHA-256: ' + sha(archive)) + + +if __name__ == '__main__': + main()