Gate: no Google non-free sign-in in apps
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run

scripts/check_no_gms_signin.sh fails when a pubspec.lock or android
gradle file pulls google_sign_in / play-services-auth / googleid, or a
built APK (--apk) carries GMS auth classes, and names
google_account_token as the fix. install_repo_contract_gate.sh now
installs its shim and an always-run hook in Flutter Android repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVQE5pYiASpRGgA3Kg
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 16:52:23 +01:00
parent 17e300af0a
commit 9daa376254
9 changed files with 529 additions and 2 deletions

View File

@ -93,6 +93,14 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
degrades to a pass, and the gate defers to that workflow when run inside
another GitHub Actions job.
## Flutter apps: no Google non-free sign-in
A repo with a `pubspec.yaml` beside an `android/` dir (root or one level
down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run
`no-gms-signin` hook (`install_gms.py`). The shared gate fails on
google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or
in a built APK with `--apk`; apps use `google_account_token` instead.
## Flutter apps: the launcher rules
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked

View File

@ -25,6 +25,7 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
from repo_contract import bootstrap
from repo_contract.check import violations
from repo_contract.infer import propose
from repo_contract.install_gms import install_gms, plan_gms
from repo_contract.install_web import install_web, plan_web
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
from repo_contract.stacks import detect_stacks
@ -64,6 +65,7 @@ def plan(repo: Path) -> list[str]:
A website (detected or declared in repo-contract.toml) also gets the
AgentReady gate: config, shim, workflow and hook (see install_web.py).
A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py).
"""
todo = []
if _needs_shim(repo):
@ -72,7 +74,7 @@ def plan(repo: Path) -> list[str]:
todo.append(str(WORKFLOW))
if needs_hook(repo, HOOK_ID):
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
return todo + plan_web(repo)
return todo + plan_web(repo) + plan_gms(repo)
def install(repo: Path) -> list[str]:
@ -92,7 +94,7 @@ def install(repo: Path) -> list[str]:
if needs_hook(repo, HOOK_ID):
write_hook(repo, _template("pre-commit-snippet.yaml"))
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
return done + install_web(repo)
return done + install_web(repo) + install_gms(repo)
def bootstrap_commands(repo: Path) -> list[str]:

View File

@ -0,0 +1,62 @@
"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate.
Why it rides on install_repo_contract_gate.sh: that installer is the one step
every new repo gets after `git init`, so a new Flutter app cannot come out of
scaffolding able to re-add google_sign_in (13 apps were migrated off it onto
utils/google_account_token in November 2026). Pieces: the
scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook.
Only repos with a pubspec.yaml next to an android/ dir (at the root or one
level down, e.g. tutor/app) get it; utils itself holds the real gate.
"""
from __future__ import annotations
from pathlib import Path
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
TEMPLATES = Path(__file__).resolve().parent / "templates"
SHIM = Path("scripts/check_no_gms_signin.sh")
HOOK_ID = "no-gms-signin"
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
SHIM_TEMPLATE = "check_no_gms_signin.shim.sh"
def _template(name: str) -> str:
return (TEMPLATES / name).read_text(encoding="utf-8")
def is_flutter_android(repo: Path) -> bool:
"""A pubspec.yaml beside an android/ dir, at the root or one level down."""
if (repo / "repo_contract" / "check.py").is_file():
return False # utils: SHIM is the real gate, never a delegate
pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")]
return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs)
def plan_gms(repo: Path) -> list[str]:
"""Which no-GMS-sign-in gate pieces are missing or drifted."""
if not is_flutter_android(repo):
return []
todo = []
shim = repo / SHIM
if not shim.is_file() or shim.read_text(encoding="utf-8") != _template(
SHIM_TEMPLATE
):
todo.append(str(SHIM))
if needs_hook(repo, HOOK_ID):
todo.append(HOOK_PIECE)
return todo
def install_gms(repo: Path) -> list[str]:
"""Write every missing or drifted no-GMS-sign-in piece. Returns them."""
todo = plan_gms(repo)
if str(SHIM) in todo:
target = repo / SHIM
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8")
target.chmod(0o755)
if HOOK_PIECE in todo:
write_hook(repo, _template("no-gms-signin-hook.yaml"))
return todo

View File

@ -0,0 +1,27 @@
#!/bin/bash
# ============================================================================
# Fail if this app links Google's non-free sign-in (google_sign_in,
# play-services-auth, googleid) instead of google_account_token.
#
# Thin delegate to the shared gate in ~/src/utils, which owns the package
# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh
# in a repo; never copy the shared gate itself.
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

View File

@ -0,0 +1,12 @@
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app).
# No Google non-free sign-in: google_sign_in links play-services-auth /
# googleid into the APK; apps use utils/google_account_token instead.
# always_run rather than `files:`: a poisoned pubspec.lock can arrive by
# pull or merge, and a staged-only trigger would never look again.
- id: no-gms-signin
name: no google_sign_in / GMS auth (use google_account_token)
entry: scripts/check_no_gms_signin.sh .
language: system
pass_filenames: false
always_run: true

View File

@ -0,0 +1,77 @@
"""A Flutter repo with an Android target gets the no-GMS-sign-in gate."""
from __future__ import annotations
import stat
import subprocess
from pathlib import Path
from repo_contract import install as inst
from repo_contract import install_gms as gms
from .conftest import write
def flutter_android(root: Path, sub: str = "") -> Path:
"""A Flutter project at `root/sub` with an android/ dir."""
base = f"{sub}/" if sub else ""
write(root, f"{base}pubspec.yaml", "name: x\n")
write(root, f"{base}android/app/build.gradle.kts", "plugins {}\n")
return root
def test_no_pubspec_means_no_gate(repo: Path) -> None:
assert gms.plan_gms(repo) == []
assert gms.install_gms(repo) == []
def test_pubspec_without_android_means_no_gate(repo: Path) -> None:
write(repo, "pubspec.yaml", "name: x\n")
assert gms.plan_gms(repo) == []
def test_root_flutter_android_gets_shim_and_hook(repo: Path) -> None:
flutter_android(repo)
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
assert inst.install(repo)[-2:] == [str(gms.SHIM), gms.HOOK_PIECE]
shim = repo / gms.SHIM
assert shim.stat().st_mode & stat.S_IXUSR
config = (repo / inst.PRECOMMIT).read_text(encoding="utf-8")
assert f"id: {gms.HOOK_ID}" in config
assert "always_run: true" in config
assert gms.plan_gms(repo) == []
assert gms.install_gms(repo) == []
def test_nested_app_dir_counts(repo: Path) -> None:
flutter_android(repo, "app")
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
def test_drifted_shim_is_rewritten_hook_kept(repo: Path) -> None:
flutter_android(repo)
gms.install_gms(repo)
write(repo, gms.SHIM.as_posix(), "#!/bin/bash\nexit 0\n")
assert gms.install_gms(repo) == [str(gms.SHIM)]
def test_utils_itself_is_never_given_a_shim(repo: Path) -> None:
flutter_android(repo, "google_account_token")
write(repo, "repo_contract/check.py")
assert gms.plan_gms(repo) == []
def test_installed_shim_delegates_to_the_shared_gate(repo: Path) -> None:
flutter_android(repo)
write(repo, "pubspec.lock", "packages:\n google_sign_in_android:\n")
gms.install_gms(repo)
utils_root = Path(__file__).resolve().parents[2]
proc = subprocess.run(
["bash", str(repo / gms.SHIM), str(repo)],
capture_output=True,
text=True,
check=False,
env={"UTILS_ROOT": str(utils_root), "PATH": "/usr/bin:/bin"},
)
assert proc.returncode == 1
assert "google_account_token" in proc.stderr

180
scripts/check_no_gms_signin.sh Executable file
View File

@ -0,0 +1,180 @@
#!/bin/bash
# ============================================================================
# Fail if a Flutter/Android app links Google's non-free sign-in stack.
#
# Every app signs in through kuhy's FOSS plugin google_account_token
# (utils/google_account_token, Android AccountManager). google_sign_in pulls
# google_sign_in_android, which links play-services-auth / googleid into the
# APK. Nine apps were migrated off it on 2026-11-02 and four more on
# 2026-11-10; nothing else stops a later session from re-adding it.
#
# Lockfile mode (default) reads pubspec.lock package keys, plus the gradle
# files under each lock's sibling android/ dir. Web-only pieces
# (google_sign_in_web, google_sign_in_platform_interface and their transitive
# google_identity_services_web) are allowed while google_sign_in_android is
# absent: kartka's web build uses them for the GIS button and they never
# reach an APK. APK mode greps the built dex for the GMS auth classes, the
# ground truth when a dependency arrives some other way.
#
# Usage: check_no_gms_signin.sh [pubspec.lock | dir ...]
# check_no_gms_signin.sh --apk app.apk
# A dir is searched for pubspec.lock files (build/, .dart_tool/ pruned).
# With no arguments, checks ./pubspec.lock (passes if there is none).
# Exit: 0 clean | 1 non-free sign-in found | 2 usage error
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly BANNED=(google_sign_in google_sign_in_android google_sign_in_ios)
readonly WEB_ONLY=(google_sign_in_web google_sign_in_platform_interface
google_identity_services_web)
# Maven coordinates (and split-form catalog names) of the GMS sign-in stack.
# Anchored so com.google.gms:google-services (Firebase) and
# play-services-auth-api-phone do not trip it.
readonly GRADLE_PATTERN='com\.google\.android\.gms:play-services-(auth|identity-credentials)([^-[:alnum:]]|$)|com\.google\.android\.libraries\.identity\.googleid|androidx\.credentials:credentials-play-services-auth|name[[:space:]]*=[[:space:]]*"(play-services-auth|play-services-identity-credentials|credentials-play-services-auth|googleid)"'
readonly DEX_PATTERN='Lcom/google/android/(gms/auth|libraries/identity/googleid)/'
readonly PRUNE=(-name build -o -name .dart_tool -o -name .git -o -name .gradle -o -name .claude
-o -name node_modules)
usage() {
echo "Usage: $SCRIPT_NAME [pubspec.lock | dir ...]"
echo " $SCRIPT_NAME --apk app.apk"
echo " Fails when an app links Google's non-free sign-in (GMS auth)."
exit 0
}
fix_hint() {
echo " Use google_account_token instead (FOSS, AccountManager):" >&2
echo " google_account_token:" >&2
echo " git:" >&2
echo " url: https://github.com/kuhyx/utils" >&2
echo " ref: google_account_token-v0.1.0" >&2
echo " path: google_account_token" >&2
echo " See utils/google_account_token/README.md." >&2
}
# Package keys of a pubspec.lock: the 2-space keys under `packages:`.
lock_packages() {
awk '
/^packages:[[:space:]]*$/ { in_packages = 1; next }
/^[^[:space:]]/ { in_packages = 0; next }
in_packages && /^ [A-Za-z0-9_]+:[[:space:]]*$/ {
sub(/^ /, ""); sub(/:.*/, ""); print
}
' "$1"
}
# Prints the offending packages of one lock, one per line.
lock_offenders() {
local -A present=()
local name
while IFS= read -r name; do
present["$name"]=1
done < <(lock_packages "$1")
for name in "${BANNED[@]}"; do
[[ -n "${present[$name]:-}" ]] && echo "$name"
done
if [[ -n "${present[google_sign_in_android]:-}" ]]; then
for name in "${WEB_ONLY[@]}"; do
[[ -n "${present[$name]:-}" ]] && echo "$name"
done
fi
return 0
}
# Prints file:line:text for every non-comment GMS sign-in coordinate under
# the lock's sibling android/ dir.
gradle_offenders() {
local android_dir="$1/android" file
[[ -d "$android_dir" ]] || return 0
while IFS= read -r -d '' file; do
grep -nE "$GRADLE_PATTERN" "$file" \
| grep -vE '^[0-9]+:[[:space:]]*(//|#|\*|/\*)' \
| sed "s|^|$file:|" || true
done < <(find "$android_dir" \( "${PRUNE[@]}" \) -prune -o -type f \
\( -name 'build.gradle*' -o -name '*.versions.toml' \) -print0)
}
indent() {
local line
while IFS= read -r line; do
echo " $line"
done
}
check_lock() {
local lock="$1" offenders gradle
offenders="$(lock_offenders "$lock")"
gradle="$(gradle_offenders "$(dirname "$lock")")"
[[ -z "$offenders" && -z "$gradle" ]] && return 0
if [[ -n "$offenders" ]]; then
echo "Error: $lock links Google's non-free sign-in:" >&2
indent <<<"$offenders" >&2
fi
if [[ -n "$gradle" ]]; then
echo "Error: gradle names the GMS sign-in stack:" >&2
indent <<<"$gradle" >&2
fi
return 1
}
check_apk() {
local apk="$1" dex count
[[ -f "$apk" ]] || { echo "Error: no such APK: $apk" >&2; exit 2; }
count="$(for dex in $(unzip -Z1 "$apk" 'classes*.dex'); do
unzip -p "$apk" "$dex"
done | strings | grep -cE "$DEX_PATTERN" || true)"
if [[ "$count" -gt 0 ]]; then
echo "Error: $apk has $count GMS sign-in class references" >&2
echo " (Lcom/google/android/gms/auth or .../identity/googleid)." >&2
fix_hint
return 1
fi
}
# Expands the arguments into pubspec.lock paths, NUL-separated.
collect_locks() {
local arg
for arg in "$@"; do
if [[ -d "$arg" ]]; then
find "$arg" \( "${PRUNE[@]}" \) -prune -o -type f \
-name pubspec.lock -print0
else
printf '%s\0' "$arg"
fi
done
}
main() {
case "${1:-}" in
-h|--help) usage ;;
--apk)
[[ $# -eq 2 ]] || { echo "Usage: $SCRIPT_NAME --apk app.apk" >&2; exit 2; }
check_apk "$2"
return
;;
esac
if [[ $# -eq 0 ]]; then
[[ -f pubspec.lock ]] || return 0
set -- pubspec.lock
fi
local -a locks=()
local arg lock failed=0
# Validated up front: collect_locks runs in a process substitution, whose
# exit status would be lost, and a typo'd path must not pass silently.
for arg in "$@"; do
[[ -e "$arg" ]] || { echo "Error: no such file or directory: $arg" >&2; exit 2; }
done
mapfile -d '' locks < <(collect_locks "$@")
for lock in "${locks[@]}"; do
check_lock "$lock" || failed=1
done
[[ "$failed" -eq 0 ]] || fix_hint
return "$failed"
}
main "$@"

View File

@ -9,6 +9,7 @@
# when the repo has .dep-freshness-no-workflow), Commands section. A website
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
# A Flutter app with android/ also gets the no-GMS-sign-in gate (shim + hook).
#
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo

View File

@ -0,0 +1,158 @@
"""check_no_gms_signin.sh: no app may link Google's non-free sign-in stack.
Each test writes a throwaway Flutter-shaped project (pubspec.lock, optional
android/ gradle files) or a fake APK built with zipfile, runs the real
script, and asserts on the exit code and the message.
"""
from __future__ import annotations
import subprocess
import zipfile
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent.parent / "check_no_gms_signin.sh"
# The package keys of kuhy's real kartka lock (web-only GIS button).
KARTKA_KEYS = (
"google_account_token",
"google_identity_services_web",
"google_sign_in_platform_interface",
"google_sign_in_web",
)
def _lock(root: Path, *packages: str) -> Path:
"""Write a pubspec.lock under `root` with the given package keys."""
root.mkdir(parents=True, exist_ok=True)
body = ["# Generated by pub", "packages:"]
for name in ("collection", *packages):
body += [
f" {name}:",
' dependency: "direct main"',
" description:",
f" name: {name}",
" source: hosted",
' version: "1.0.0"',
]
body += ["sdks:", ' dart: ">=3.0.0 <4.0.0"', ""]
lock = root / "pubspec.lock"
lock.write_text("\n".join(body), encoding="utf-8")
return lock
def _run(*args: str | Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", str(SCRIPT), *map(str, args)],
capture_output=True,
text=True,
check=False,
)
def test_clean_lock_passes(tmp_path: Path) -> None:
lock = _lock(tmp_path, "google_account_token", "http")
assert _run(lock).returncode == 0
def test_google_sign_in_android_fails_and_names_the_fix(tmp_path: Path) -> None:
lock = _lock(tmp_path, "google_sign_in_android")
proc = _run(lock)
assert proc.returncode == 1
assert str(lock) in proc.stderr
assert "google_sign_in_android" in proc.stderr
assert "google_account_token-v0.1.0" in proc.stderr
assert "google_account_token/README.md" in proc.stderr
def test_app_facing_google_sign_in_fails(tmp_path: Path) -> None:
proc = _run(_lock(tmp_path, "google_sign_in"))
assert proc.returncode == 1
assert "google_sign_in " in proc.stderr or "google_sign_in\n" in proc.stderr
def test_kartka_style_web_only_passes(tmp_path: Path) -> None:
assert _run(_lock(tmp_path, *KARTKA_KEYS)).returncode == 0
def test_web_pieces_fail_once_android_is_linked(tmp_path: Path) -> None:
proc = _run(_lock(tmp_path, *KARTKA_KEYS, "google_sign_in_android"))
assert proc.returncode == 1
assert "google_sign_in_web" in proc.stderr
def test_gradle_play_services_auth_fails(tmp_path: Path) -> None:
lock = _lock(tmp_path)
gradle = tmp_path / "android" / "app" / "build.gradle.kts"
gradle.parent.mkdir(parents=True)
gradle.write_text(
'dependencies {\n implementation("com.google.gms:google-services:4")\n'
' implementation("com.google.android.gms:play-services-auth:21.3.0")\n}\n',
encoding="utf-8",
)
proc = _run(lock)
assert proc.returncode == 1
assert str(gradle) in proc.stderr
assert "play-services-auth" in proc.stderr
def test_versions_toml_googleid_fails(tmp_path: Path) -> None:
lock = _lock(tmp_path)
toml = tmp_path / "android" / "gradle" / "libs.versions.toml"
toml.parent.mkdir(parents=True)
toml.write_text(
'googleid = "com.google.android.libraries.identity.googleid:googleid:1.1"\n',
encoding="utf-8",
)
assert _run(lock).returncode == 1
def test_firebase_google_services_plugin_passes(tmp_path: Path) -> None:
lock = _lock(tmp_path)
gradle = tmp_path / "android" / "build.gradle"
gradle.parent.mkdir(parents=True)
gradle.write_text(
"// play-services-auth was removed 2026-11-02\n"
"classpath 'com.google.gms:google-services:4.4.2'\n",
encoding="utf-8",
)
assert _run(lock).returncode == 0
def test_directory_mode_finds_nested_locks_and_prunes_build(tmp_path: Path) -> None:
_lock(tmp_path / "app", "google_account_token")
_lock(tmp_path / "app" / "build" / "x", "google_sign_in_android")
_lock(tmp_path / ".dart_tool" / "y", "google_sign_in_android")
# Agent worktrees are other checkouts, judged in their own repo.
_lock(tmp_path / ".claude" / "worktrees" / "z", "google_sign_in_android")
assert _run(tmp_path).returncode == 0
_lock(tmp_path / "other", "google_sign_in_ios")
proc = _run(tmp_path)
assert proc.returncode == 1
assert "google_sign_in_ios" in proc.stderr
def test_missing_explicit_file_is_a_usage_error(tmp_path: Path) -> None:
assert _run(tmp_path / "nope.lock").returncode == 2
def _apk(path: Path, dex: bytes) -> Path:
with zipfile.ZipFile(path, "w") as apk:
apk.writestr("AndroidManifest.xml", b"\x03\x00")
apk.writestr("classes.dex", b"dex\n035\x00" + dex)
apk.writestr("classes2.dex", b"dex\n035\x00Lkotlin/Unit;\x00")
return path
def test_apk_with_gms_auth_classes_fails(tmp_path: Path) -> None:
dex = b"\x00Lcom/google/android/gms/auth/api/signin/GoogleSignIn;\x00" * 2
proc = _run("--apk", _apk(tmp_path / "bad.apk", dex))
assert proc.returncode == 1
assert "2" in proc.stderr
assert "google_account_token" in proc.stderr
def test_apk_without_gms_auth_classes_passes(tmp_path: Path) -> None:
dex = b"\x00Lcom/google/android/gms/common/api/Status;\x00"
proc = _run("--apk", _apk(tmp_path / "good.apk", dex))
assert proc.returncode == 0, proc.stderr