Give the two untested CI gates a self-test, and mechanise the rule (#705) (#759)
Some checks are pending
Android CI / test (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Build & Test (push) Waiting to run

* Give the two untested CI gates a self-test, and mechanise the rule (#705)

`scripts/lib/gate-filters.sh` opens with the rule, deliberately as the first
thing a new gate author reads: every `scripts/check-*.sh` MUST ship a matching
`scripts/tests/<gate>-test.sh` and MUST have it wired into CI -- "that
requirement, not this file, is the part that catches the bug". It was a
comment, and on main it was false for two of the nine gates.

check-apk-distribution.sh is the one that matters. release.yml runs it as
"Staged FOSS APK contains no Google/Firebase code"; it is the whole of #548's
answer, and a violation would be invisible until someone decompiled a shipped
APK. It had never been observed going red. Being invoked in CI is not the same
as being known to work -- #678's step-order gate ran on every PR for months
while exiting 1 with zero output on the two inputs it existed to catch, and
#686's backfill exited 0 after a failed secret push.

Every case in both harnesses is a demonstrated control, red case first.

scripts/tests/check-apk-distribution-test.sh (42 cases). FIXTURES, NOT REAL
APKs: the gate reads an APK as a zip of files and greps the bytes, so a zip of
text files exercises every path it has -- the 1MB size floor, the dex-presence
check and the com/rousecontext control included -- in about two seconds with no
SDK. Red: firebase and gms DEX class references separately; each of the three
google-services string resources on its own; a dot-form provider in the
manifest; res/raw/firebase_common_keep.xml; --expect google over a clean APK
(the double-staging case release.yml's shared output path makes possible);
firebase without gms; a resources-only marker not satisfying --expect google;
and the five preflight refusals (missing, too small, not a zip, no dex, not
this app). Green: a clean fixture, and -- pinned because it is the judgement
most likely to be "tidied" later -- the four dot-form GMS strings a genuinely
clean FOSS build carries in its DEX, alongside the same string in the manifest
asserted RED, so the slash-vs-dot asymmetry is measured rather than assumed.
Part 5 mutates the gate so a pattern stops matching its own sample, and
requires the gate's own anti-vacuity self-test to fire (#579's failure mode).

scripts/tests/check-zombie-screens-test.sh (36 cases). #657 already found this
gate printing "OK: no zombie screens" and exiting 0 over a tree containing one,
with a stubbed-failing `awk`, so that red case is pinned rather than described:
the real gate is run with a PATH-shimmed failing `awk` and must fail by name,
and a reconstruction of the pre-#657 shape must still produce the lie on the
same tree -- otherwise the first assertion could be passing for an unrelated
reason. Also pinned: self-reference does not rescue a screen, nor a test-only
reference, nor a non-Kotlin file, nor a substring of the name; the exemption
list works and covers only the file it names. Three residues are asserted
rather than left implicit -- one live composable vouches for its whole file, a
PUBLIC @Preview composable is NOT exempt (only `private fun` is dropped), and
the gate reports OK when its screens directory does not exist, which is
green-by-absence and is filed as #758.

scripts/check-gate-selftests.sh then makes the rule mechanical: every
check-*.sh has a harness, and every harness is named in a workflow. The second
half matters more than it looks -- a harness nobody runs is a file, not a gate.
Discovery is `git ls-files`, membership is tested against the TRACKED list (an
untracked harness satisfies `[[ -f ]]` locally and does not exist in CI's
checkout), and zero gates, zero harnesses or zero workflows is a FAILURE rather
than a vacuous pass. The predicate is name-based for a content-based class --
the objection #658 raises about the shellcheck gate's `*.sh` -- and the header
says so explicitly instead of inheriting it silently: the convention is uniform
across all nine gates, proximity enforces it, and the miss direction is safe (a
gate not named check-* escapes the check; it cannot cause a false red).
backfill-relay-secrets.sh is deliberately outside the gate set, and its harness
is still covered by the wiring half -- nothing forces it to HAVE a self-test,
but having one means it must be RUN.

Its own harness (27 cases) covers both gaps, both together, the untracked-
harness case, and every way the gate could look at an empty set and call it
clean -- no gates, no harnesses, no workflows, not a work tree, no such
directory. A coverage check is the easiest kind to make vacuous (#629 proved
comparisons happened, not that they could detect anything).

All three harnesses read the gate's output with `grep` over a FILE, never
`printf ... | grep -q`. That pipeline folds three outcomes into one answer --
no match, grep could not answer, and grep matching EARLY and `pipefail`
reporting the writer's SIGPIPE for `!` to invert into "no match" -- and the
last is payload-size dependent and tracked as a live defect in #725. With no
pipe there is no writer to kill.

Four steps added to ci.yml's lint job, next to the five self-tests already
there. release.yml is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYSPuh9bZEJkrzvnEpY1QK

* TEMPORARY: a gate with no self-test, to watch #705's check go red in CI

Reverted in the next commit. `scripts/check-gate-selftests.sh` is itself a
gate, and a gate whose red case has only ever been exercised against fixtures
in its own harness is exactly what #705 is about. This makes the real CI step
fail on the real tree, once, so the link is citable.

Expected: the "Every gate ships a wired self-test (#705)" step in the lint job
exits 1 with

  1 gate(s) ship with no self-test:
    scripts/check-fixture-no-harness.sh -> scripts/tests/check-fixture-no-harness-test.sh

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYSPuh9bZEJkrzvnEpY1QK

* Revert the temporary no-harness fixture gate

The point is made: run 34925372964, job Lint, step "Every gate ships a wired
self-test (#705)" exited 1 on the real tree with

  1 gate(s) ship with no self-test:
    scripts/check-fixture-no-harness.sh -> scripts/tests/check-fixture-no-harness-test.sh

and the two new self-test steps ahead of it passed on the same run. The
coverage gate is now a demonstrated red, not an assumed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYSPuh9bZEJkrzvnEpY1QK

---------

Co-authored-by: Monkopedia <monkopedia@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
monkopedia-coder 2026-09-15 00:20:07 -04:00 • committed by GitHub
parent fee84e61f4
commit 3783728e2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1489 additions and 0 deletions

View file

@ -150,6 +150,32 @@ jobs:
# test stubs `gh` and `gcloud`, so it never reaches GitHub or the VPS.
- name: "backfill-relay-secrets self-test (#686)"
run: bash scripts/tests/backfill-relay-secrets-test.sh
# The FOSS-purity gate. release.yml runs it as "Staged FOSS APK contains
# no Google/Firebase code" -- the whole of #548's answer -- and until #705
# it had never been observed going red. Fixtures, not real APKs: the gate
# reads an APK as a zip of files and greps the bytes, so a zip of text
# files exercises every path it has, in a second, with no SDK.
- name: "APK distribution gate self-test (#705, #548)"
run: bash scripts/tests/check-apk-distribution-test.sh
# #657 measured this gate printing "OK: no zombie screens" and exiting 0
# over a tree containing one, with a stubbed-failing `awk`. That red case
# is pinned here, alongside a reconstruction of the pre-#657 shape that
# must still produce the lie -- so the pin cannot pass for an unrelated
# reason.
- name: "Zombie-screens gate self-test (#705, #657)"
run: bash scripts/tests/check-zombie-screens-test.sh
# The rule the two steps above satisfy, made mechanical: every
# scripts/check-*.sh has a scripts/tests/<gate>-test.sh, and every
# self-test is invoked by a workflow. It was a comment in
# scripts/lib/gate-filters.sh, and on main it was false.
- name: "Every gate ships a wired self-test (#705)"
run: bash scripts/check-gate-selftests.sh
# ...and the coverage gate is itself a gate, so it has its own harness.
# A coverage check is the easiest kind to make vacuous -- discover
# nothing, compare nothing, report clean (#629) -- so every way it could
# look at an empty set is a red case in here.
- name: "Gate self-test coverage gate self-test (#705)"
run: bash scripts/tests/check-gate-selftests-test.sh
- uses: actions/setup-java@v4
with:
distribution: temurin

200
scripts/check-gate-selftests.sh Executable file
View file

@ -0,0 +1,200 @@
#!/usr/bin/env bash
# Fails if a CI gate ships without a self-test, or if a self-test is not wired
# into a workflow (#705).
#
# WHY THIS EXISTS
# ---------------
# The rule was already written down. `scripts/lib/gate-filters.sh` puts it
# first, in a block headed READ THIS FIRST IF YOU ARE ADDING A GATE:
#
# Every `scripts/check-*.sh` MUST ship a matching
# `scripts/tests/<gate>-test.sh` and MUST have that self-test wired into CI.
# That requirement -- not this file -- is the part that catches the bug.
#
# It was a comment, and on `main` it was false: `check-apk-distribution.sh` --
# the FOSS-purity gate `release.yml` runs as the whole of #548's answer -- and
# `check-zombie-screens.sh` had no harness at all. Both were fixed in the change
# that added this file; this is what stops the tenth gate arriving without one.
#
# The argument for mechanising it is the repo's own history. A gate that has
# only ever been observed passing is indistinguishable from one that cannot
# fail, and this tree has shipped that four times:
#
# #577/#579 a post-filter silently swallowing real violations, reporting clean
# #657 a stubbed `awk` making check-zombie-screens.sh print OK over a zombie
# #678 check-roborazzi-step-order.sh exiting 1 with zero output -- a crash
# reading as a finding -- on the two inputs it existed to catch
# #686 backfill-relay-secrets.sh exiting 0 after a failed secret push
#
# Every one was caught by a self-test, or by a human noticing what a self-test
# would have caught. None was caught by the gate itself running in CI.
#
# WHAT COUNTS AS A GATE -- a name-based predicate, stated rather than inherited
# ---------------------------------------------------------------------------
# The set is `git ls-files 'scripts/check-*.sh'`: a NAME-based predicate for
# what is really a content-based class, which is the objection #658 raises
# against the shellcheck gate's `*.sh`. It is accepted here deliberately, and
# the reasons are worth stating so the next reader does not have to guess:
#
# * the convention is real and uniform -- all nine gates on `main` when this
# landed are named `check-<subject>.sh`, and each is invoked from a workflow
# under that name;
# * proximity enforces it -- a new gate is written next to nine files that
# follow the convention, and its CI step is added next to theirs;
# * the failure direction is safe. A script that IS a gate but is not named
# `check-*` escapes this check; it does not cause a false red. The cost of
# the miss is the status quo ante, not a broken build.
#
# `scripts/backfill-relay-secrets.sh` is deliberately OUTSIDE the set: it is not
# a gate, it writes the production relay secrets. It happens to have a harness
# (#686, after it exited 0 over a failed push) and that harness is wired into
# CI, so the second half of this check covers it -- which is the right split.
# Being outside the gate set means nothing forces it to HAVE a self-test; having
# one means the self-test must be RUN.
#
# WHAT IS CHECKED
# ---------------
# 1. every `scripts/check-*.sh` has `scripts/tests/<name>-test.sh`;
# 2. every `scripts/tests/*-test.sh` is named in a `.github/workflows/*.yml`.
#
# (2) is the half that matters more than it looks. A harness nobody runs is a
# file, not a gate, and it rots silently: the gate it covers keeps passing CI
# while the thing that would notice a lie sits unexecuted. It also covers this
# script's own harness, and every harness for a non-`check-` subject.
#
# HOW IT FAILS
# ------------
# Loudly, by name, and never green by absence: zero gates, zero harnesses or
# zero workflow files is a FAILURE, because each of those is a way for this
# check to look at nothing and call it clean -- the #547/#579 shape it exists to
# prevent. Discovery is `git ls-files`, so it refuses to run outside a work
# tree for the same reason `check-shell-lint.sh` does.
set -euo pipefail
root=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}
if [[ ! -d "$root" ]]; then
echo "FAIL: no such directory: $root" >&2
exit 1
fi
cd "$root"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "FAIL: $root is not a git work tree." >&2
echo " Discovery is 'git ls-files', so outside a work tree this gate" >&2
echo " would find nothing and 'pass'. It fails instead." >&2
exit 1
fi
# list_tracked <what> <pathspec...> -- tracked files matching a pathspec, into
# `tracked`.
#
# `git ls-files` is read with its status CHECKED, and the emptiness test happens
# BEFORE the split. Both matter. A process substitution reports no status at
# all, so a failed `git` would look like an empty repo (the #657 shape); and
# `mapfile -t a <<<""` yields a ONE-element array holding the empty string,
# which would sail past a `${#a[@]} -gt 0` test and then be used as a filename.
tracked=()
list_tracked() {
local what=$1 raw status
shift
set +e
raw=$(git ls-files -- "$@")
status=$?
set -e
if [[ "$status" -ne 0 ]]; then
echo "FAIL: 'git ls-files -- $*' exited $status looking for $what." >&2
echo " Nothing was discovered, so this is a failure and not a pass." >&2
exit 1
fi
if [[ -z "$raw" ]]; then
echo "FAIL: no $what found (pathspec: $*)." >&2
echo " An empty discovery would make every check below vacuously" >&2
echo " true, so it is reported as a failure." >&2
exit 1
fi
mapfile -t tracked <<<"$raw"
}
list_tracked 'CI gates' 'scripts/check-*.sh'
gates=("${tracked[@]}")
list_tracked 'gate self-tests' 'scripts/tests/*-test.sh'
harnesses=("${tracked[@]}")
list_tracked 'workflow files' '.github/workflows/*.yml' '.github/workflows/*.yaml'
workflows=("${tracked[@]}")
echo "Checking gate self-test coverage"
echo " gates : ${#gates[@]} (scripts/check-*.sh)"
echo " self-tests : ${#harnesses[@]} (scripts/tests/*-test.sh)"
echo " workflows : ${#workflows[@]}"
echo
# Membership is tested against the TRACKED list, not the filesystem: an
# untracked harness satisfies `[[ -f ]]` locally and does not exist in CI's
# checkout, which is the one place this check's answer matters. Same trade
# `check-shell-lint.sh` makes, and the same reason `git add` before running
# either of them locally is worth the habit.
harness_list=$'\n'
for harness in "${harnesses[@]}"; do
harness_list+="$harness"$'\n'
done
missing_harness=()
for gate in "${gates[@]}"; do
name=$(basename "$gate" .sh)
harness="scripts/tests/$name-test.sh"
if [[ "$harness_list" == *$'\n'"$harness"$'\n'* ]]; then
printf ' %-10s %-40s %s\n' 'HAS' "$name" "$harness"
else
printf ' %-10s %-40s %s\n' 'NONE' "$name" "(expected $harness)"
missing_harness+=("$gate -> $harness")
fi
done
echo
# Which workflow, if any, runs this harness. The search is for the harness's
# own path, which is how every wired step in this repo spells it
# (`run: bash scripts/tests/<name>-test.sh`).
unwired=()
for harness in "${harnesses[@]}"; do
set +e
hits=$(grep -lF -e "$harness" -- "${workflows[@]}")
status=$?
set -e
if [[ "$status" -gt 1 ]]; then
echo "FAIL: grep exited $status looking for '$harness' in the workflow files." >&2
echo " The search did not complete; this is a failure, not a pass." >&2
exit 1
fi
if [[ "$status" -eq 0 ]]; then
printf ' %-10s %-40s %s\n' 'WIRED' "$(basename "$harness")" "${hits//$'\n'/ }"
else
printf ' %-10s %-40s %s\n' 'UNWIRED' "$(basename "$harness")" '(no workflow runs it)'
unwired+=("$harness")
fi
done
echo
if [[ "${#missing_harness[@]}" -gt 0 || "${#unwired[@]}" -gt 0 ]]; then
echo "GATE SELF-TEST CHECK FAILED (issue #705)" >&2
echo >&2
if [[ "${#missing_harness[@]}" -gt 0 ]]; then
echo " ${#missing_harness[@]} gate(s) ship with no self-test:" >&2
printf ' %s\n' "${missing_harness[@]}" >&2
echo >&2
fi
if [[ "${#unwired[@]}" -gt 0 ]]; then
echo " ${#unwired[@]} self-test(s) are not invoked by any workflow:" >&2
printf ' %s\n' "${unwired[@]}" >&2
echo >&2
fi
echo " A gate that has only ever been observed passing is indistinguishable" >&2
echo " from one that cannot fail. Write the harness -- red case FIRST, so the" >&2
echo " gate is demonstrated capable of failing -- and add a step that runs it." >&2
echo " scripts/tests/check-zombie-screens-test.sh is a short example; see also" >&2
echo " the rule in scripts/lib/gate-filters.sh." >&2
exit 1
fi
echo "OK: all ${#gates[@]} gate(s) have a self-test, and all ${#harnesses[@]} self-test(s) are wired into a workflow."

View file

@ -0,0 +1,473 @@
#!/usr/bin/env bash
# Regression tests for scripts/check-apk-distribution.sh (#705, #548).
#
# WHY THIS FILE EXISTS
# --------------------
# `check-apk-distribution.sh` is the FOSS-purity gate: release.yml runs it as
# "Staged FOSS APK contains no Google/Firebase code", and it is the whole of
# #548's answer. Until this file, it had never been observed going RED. That is
# the distinction half this repo's gate work is about -- #678's step-order gate
# ran on every PR for months while exiting 1 with zero output on the two inputs
# it existed to catch, and #686's backfill exited 0 after a failed push. A gate
# that has only ever been seen passing is indistinguishable from one that
# cannot fail, and this particular one guards a property (no Firebase in the
# F-Droid artifact) whose violation would be invisible until somebody
# decompiled a shipped APK.
#
# So every case below is a demonstrated control in BOTH directions: a fixture
# that CONTAINS what the gate exists to catch, asserted red and asserted to NAME
# what it found; and a clean fixture asserted green.
#
# NO REAL APK IS BUILT OR DOWNLOADED. The gate reads an APK as a zip of files
# and greps the bytes, so a zip of text files exercises every path it has --
# including the size floor, the dex-presence check, and the `com/rousecontext`
# control. Building a real APK here would make this suite need a JDK, an SDK
# and four minutes, and would test Gradle rather than the gate.
set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
gate=$repo_root/scripts/check-apk-distribution.sh
sandbox=$(mktemp -d)
trap 'rm -rf "$sandbox"' EXIT
failures=0
out_file=$sandbox/gate-output.txt
stage_dir=$sandbox/stage
run_status=0
run_ok=0
has_verdict=
has_status=0
if ! command -v zip >/dev/null 2>&1; then
echo "FAIL: 'zip' is not installed; the fixture APKs cannot be built." >&2
echo " Skipping would make this suite green by absence, so it fails." >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Reading the gate's output.
#
# Deliberately `grep` over a FILE, never `printf ... | grep -q` (#716/#725).
# That pipeline has three outcomes the caller cannot tell apart: grep found
# nothing; grep never answered; or grep exited early ON A MATCH and the writer
# took SIGPIPE, which `pipefail` then reports as the pipeline's status and `!`
# inverts into "no match". The last is payload-size dependent -- measured clean
# at 4.4KB and failing 15116 times in 20000 at 32KB -- and this gate's failure
# output grows with the number of markers it found, which is precisely the case
# these assertions run on. With no pipe there is no writer to kill, so the shape
# cannot arise; grep's own status is read, and a status above 1 (it could not
# answer) is reported as its own thing rather than folded into "absent".
# ---------------------------------------------------------------------------
out_has() {
set +e
grep -qF -e "$1" "$out_file"
has_status=$?
set -e
case "$has_status" in
0) has_verdict=yes ;;
1) has_verdict=no ;;
*) has_verdict=broken ;;
esac
}
fail_case() {
echo "FAIL: $*"
sed 's/^/ /' "$out_file"
failures=$((failures + 1))
}
# ---------------------------------------------------------------------------
# Fixture APKs.
#
# `stage_clean` lays out the minimum the gate demands of ANY artifact before it
# will render a verdict: a top-level classes.dex, app code inside it, and a
# megabyte of bulk. Individual cases then add the markers they are about.
#
# The padding is stored (`zip -0`) rather than deflated because the gate's size
# floor reads the FILE, and a megabyte of one repeated byte compresses to
# nothing. It is a run of 'A': no pattern in the gate matches it, so it cannot
# accidentally become the thing a case is measuring.
# ---------------------------------------------------------------------------
stage_clean() {
rm -rf "$stage_dir"
mkdir -p "$stage_dir/res/raw" "$stage_dir/assets" "$stage_dir/META-INF"
# DEX type descriptors: slash-form, `L...;`-wrapped, as the real thing is.
printf '%s\n' \
'Lcom/rousecontext/app/MainActivity;' \
'Lcom/rousecontext/app/ui/AppNavigation;' \
'Lcom/rousecontext/core/mcp/McpServer;' > "$stage_dir/classes.dex"
printf '%s\n' '<manifest package="com.rousecontext"><application/></manifest>' \
> "$stage_dir/AndroidManifest.xml"
printf '%s\n' 'app_name' 'rouse_context_label' > "$stage_dir/resources.arsc"
printf '%s\n' 'MANIFEST-VERSION: 1.0' > "$stage_dir/META-INF/MANIFEST.MF"
head -c 1100000 /dev/zero | tr '\0' 'A' > "$stage_dir/assets/pad.bin"
}
# seal <name> -> the path of the fixture APK, in $apk
seal() {
apk=$sandbox/$1
rm -f "$apk"
( cd "$stage_dir" && zip -q -0 -r -X "$apk" . )
}
# ---------------------------------------------------------------------------
# run <want-exit> <label> <gate args...>
#
# Reports a status mismatch itself and sets `run_ok`, so the content assertions
# below can bail without putting a function in a condition (SC2310).
# ---------------------------------------------------------------------------
run() {
local want=$1 label=$2
shift 2
set +e
bash "$gate" "$@" > "$out_file" 2>&1
run_status=$?
set -e
if [ "$run_status" -ne "$want" ]; then
run_ok=0
fail_case "$label -- expected exit $want, got $run_status"
return
fi
run_ok=1
echo "ok: $label (exit $run_status)"
}
# must_say <label> <fixed string> ...
#
# Exiting non-zero is not enough: #678's gate exited 1 with zero bytes of
# output, which reads as a finding and is a crash. A red run has to NAME what
# it found, or nobody can act on it.
must_say() {
local label=$1 s
shift
if [ "$run_ok" -ne 1 ]; then
return
fi
for s in "$@"; do
out_has "$s"
if [ "$has_verdict" = broken ]; then
fail_case "$label -- the output read itself failed (grep exited $has_status)"
return
fi
if [ "$has_verdict" = no ]; then
fail_case "$label -- output never contained: $s"
return
fi
done
echo "ok: $label names what it found"
}
# must_not_say <label> <fixed string> ...
must_not_say() {
local label=$1 s
shift
if [ "$run_ok" -ne 1 ]; then
return
fi
for s in "$@"; do
out_has "$s"
if [ "$has_verdict" = broken ]; then
fail_case "$label -- the output read itself failed (grep exited $has_status)"
return
fi
if [ "$has_verdict" = yes ]; then
fail_case "$label -- output should not have contained: $s"
return
fi
done
echo "ok: $label stays silent about $*"
}
# ===========================================================================
# Part 1 -- RED. Each fixture contains the thing the gate exists to catch.
# ===========================================================================
# The headline case: Firebase class references in the DEX of an APK published
# as FOSS. This is the shape an ambient ORG_GRADLE_PROJECT_google, a stray
# gradle.properties line, or a mis-ordered stage step in release.yml produces.
stage_clean
printf '%s\n' \
'Lcom/google/firebase/messaging/FirebaseMessaging;' \
'Lcom/google/firebase/iid/FirebaseInstanceId;' >> "$stage_dir/classes.dex"
seal firebase-dex.apk
run 1 'firebase DEX refs fail --expect foss' --apk "$apk" --expect foss
must_say 'firebase DEX refs fail --expect foss' \
'Google/Firebase code found in a build published as FOSS.' \
'dex pattern: com/google/firebase' \
'in: classes.dex' \
'Google/Firebase packages linked in:' \
'com/google/firebase/messaging' \
'APK DISTRIBUTION CHECK FAILED (issue #548)'
# A gate that says FAILED and OK in the same run has told the reader nothing.
must_not_say 'firebase DEX refs fail --expect foss' 'FOSS distribution confirmed'
# Play Services without Firebase. Separate pattern, separate case: an
# alternation that silently loses one arm is the #579 shape.
stage_clean
printf '%s\n' \
'Lcom/google/android/gms/common/api/GoogleApiClient;' \
'Lcom/google/android/gms/tasks/Task;' >> "$stage_dir/classes.dex"
seal gms-dex.apk
run 1 'gms DEX refs fail --expect foss' --apk "$apk" --expect foss
must_say 'gms DEX refs fail --expect foss' \
'dex pattern: com/google/android/gms' \
'com/google/android/gms/common'
# The half a DEX-only check would miss, and the reason the gate has two scopes:
# the google-services plugin generates `google_app_id` / `gcm_defaultSenderId` /
# `firebase_database_url` STRING RESOURCES from google-services.json, and
# Firebase ships res/raw/firebase_*_keep.xml. An APK can carry the project
# credentials with no Firebase classes linked at all, and that is still not a
# FOSS artifact. One case per resource marker -- each is its own pattern.
for marker in google_app_id gcm_defaultSenderId firebase_database_url; do
stage_clean
printf '%s\n' "$marker" >> "$stage_dir/resources.arsc"
seal "res-$marker.apk"
run 1 "resources-only $marker fails --expect foss" --apk "$apk" --expect foss
must_say "resources-only $marker fails --expect foss" \
"meta $marker" \
'in: resources.arsc'
done
# Component class names in the manifest are written in DOT form, which is why
# the non-dex scope accepts either separator. A manifest naming
# FirebaseInitProvider is a Firebase-initialising APK whatever its DEX holds.
stage_clean
printf '%s\n' \
'<provider android:name="com.google.firebase.provider.FirebaseInitProvider"/>' \
>> "$stage_dir/AndroidManifest.xml"
seal manifest-dotform.apk
run 1 'dot-form manifest provider fails --expect foss' --apk "$apk" --expect foss
must_say 'dot-form manifest provider fails --expect foss' \
'meta com[./]google[./]firebase' \
'in: AndroidManifest.xml'
# res/raw/firebase_common_keep.xml -- shipped by the Firebase AARs, so it is
# present whenever they are linked, and it is a filename rather than content.
stage_clean
printf '%s\n' '<resources><item>com.google.firebase.components</item></resources>' \
> "$stage_dir/res/raw/firebase_common_keep.xml"
seal res-raw-keep.apk
run 1 'res/raw firebase keep file fails --expect foss' --apk "$apk" --expect foss
must_say 'res/raw firebase keep file fails --expect foss' \
'in: res/raw/firebase_common_keep.xml'
# The OTHER direction, and it is not decoration: both variants write the same
# unprefixed output path, so staging the FOSS APK twice under two names is a
# live failure mode (see release.yml). `--expect google` over a clean APK is
# what notices, and it doubles as the positive control that keeps the FOSS
# patterns from rotting into ones that match nothing.
stage_clean
seal clean-for-google.apk
run 1 'a clean APK fails --expect google' --apk "$apk" --expect google
must_say 'a clean APK fails --expect google' \
'was expected to be the GOOGLE build' \
'firebase=0 gms=0'
# Firebase but no Play Services: `--expect google` requires BOTH class-reference
# markers, so a resources-only or half-linked artifact cannot pass as the Google
# build.
stage_clean
printf '%s\n' 'Lcom/google/firebase/messaging/FirebaseMessaging;' >> "$stage_dir/classes.dex"
seal firebase-only.apk
run 1 'firebase without gms fails --expect google' --apk "$apk" --expect google
must_say 'firebase without gms fails --expect google' 'firebase=1 gms=0'
# A `google_app_id` string with no linked classes must not pass as the Google
# build either -- the `--expect google` arm counts DEX class references
# specifically, for exactly this reason.
stage_clean
printf '%s\n' 'google_app_id' >> "$stage_dir/resources.arsc"
seal res-only-for-google.apk
run 1 'resources-only markers do not satisfy --expect google' --apk "$apk" --expect google
must_say 'resources-only markers do not satisfy --expect google' 'firebase=0 gms=0'
# ===========================================================================
# Part 2 -- RED on the preflight. "Clean" must mean "we looked", and every one
# of these is a way to look at nothing and call it clean (#577/#579).
# ===========================================================================
run 1 'a missing APK fails' --apk "$sandbox/does-not-exist.apk" --expect foss
must_say 'a missing APK fails' 'APK not found' 'nothing was inspected'
printf 'tiny\n' > "$sandbox/tiny.apk"
run 1 'an implausibly small APK fails' --apk "$sandbox/tiny.apk" --expect foss
must_say 'an implausibly small APK fails' 'too small to be a real build'
head -c 1100000 /dev/zero | tr '\0' 'Z' > "$sandbox/not-a-zip.apk"
run 1 'an unreadable (non-zip) APK fails' --apk "$sandbox/not-a-zip.apk" --expect foss
must_say 'an unreadable (non-zip) APK fails' 'the APK could not be read'
# A zip with no classes*.dex: the code is not where the gate looks, so its
# verdict would be about nothing.
stage_clean
rm -f "$stage_dir/classes.dex"
seal no-dex.apk
run 1 'an APK with no classes.dex fails' --apk "$apk" --expect foss
must_say 'an APK with no classes.dex fails' 'no classes*.dex'
# The control that separates "scanned the app and found nothing" from "scanned
# something that is not the app". A DEX with no com/rousecontext in it is the
# wrong artifact, and a clean verdict over it means nothing.
stage_clean
printf '%s\n' 'Landroidx/core/app/NotificationCompat;' > "$stage_dir/classes.dex"
seal not-the-app.apk
run 1 'an APK that is not this app fails the control' --apk "$apk" --expect foss
must_say 'an APK that is not this app fails the control' \
'control: found 0 references to com/rousecontext'
# ===========================================================================
# Part 3 -- GREEN. The cases that must NOT fire, or the gate gets loosened back
# into uselessness by the first person it blocks.
# ===========================================================================
stage_clean
seal clean-foss.apk
run 0 'a clean fixture passes --expect foss' --apk "$apk" --expect foss
must_say 'a clean fixture passes --expect foss' \
'FOSS distribution confirmed' \
'self-test : all 2 dex + 5 non-dex patterns match the synthetic fixture' \
'control : 3 com/rousecontext references in the DEX'
# The four dot-form GMS strings a genuinely clean FOSS build carries in its DEX:
# AndroidX's photo-picker Intent actions and the Conscrypt provider name. No GMS
# code is linked. Requiring the SLASH form in the dex scope is what separates
# them, and it is the single judgement in this gate most likely to be "tidied"
# later by someone who reads `com.google.android.gms` and assumes a bug -- so it
# is pinned here rather than left to the comment that explains it.
stage_clean
printf '%s\n' \
'com.google.android.gms' \
'com.google.android.gms.org.conscrypt' \
'com.google.android.gms.provider.action.PICK_IMAGES' \
'com.google.android.gms.provider.extra.PICK_IMAGES_MAX' >> "$stage_dir/classes.dex"
seal dotform-dex.apk
run 0 'dot-form GMS strings in the DEX are not a violation' --apk "$apk" --expect foss
must_say 'dot-form GMS strings in the DEX are not a violation' 'FOSS distribution confirmed'
# The flip side of the same judgement, so the asymmetry is measured rather than
# assumed: the SAME string in the manifest IS a violation, because a component
# name there means a linked component.
stage_clean
printf '%s\n' '<service android:name="com.google.android.gms.measurement.AppMeasurementService"/>' \
>> "$stage_dir/AndroidManifest.xml"
seal dotform-manifest.apk
run 1 'the same dot-form string in the manifest IS a violation' --apk "$apk" --expect foss
must_say 'the same dot-form string in the manifest IS a violation' 'in: AndroidManifest.xml'
# A real Google build: both class-reference markers present.
stage_clean
printf '%s\n' \
'Lcom/google/firebase/messaging/FirebaseMessaging;' \
'Lcom/google/android/gms/common/api/GoogleApiClient;' >> "$stage_dir/classes.dex"
printf '%s\n' 'google_app_id' 'gcm_defaultSenderId' >> "$stage_dir/resources.arsc"
seal google.apk
run 0 'a Google fixture passes --expect google' --apk "$apk" --expect google
must_say 'a Google fixture passes --expect google' \
'Google distribution confirmed' \
'firebase=1' \
'gms=1'
# ===========================================================================
# Part 4 -- usage errors exit 2, not 1. The distinction is what stops a
# mistyped invocation in a workflow from reading as a purity finding.
# ===========================================================================
stage_clean
seal usage.apk
run 2 'a missing --expect is a usage error' --apk "$apk"
must_say 'a missing --expect is a usage error' "--expect must be 'foss' or 'google'"
run 2 'a bogus --expect is a usage error' --apk "$apk" --expect fosss
must_say 'a bogus --expect is a usage error' "got 'fosss'"
run 2 'a missing --apk is a usage error' --expect foss
must_say 'a missing --apk is a usage error' '--apk is required'
run 2 'an unknown argument is a usage error' --apk "$apk" --expect foss --wat
must_say 'an unknown argument is a usage error' "unknown argument '--wat'"
# ===========================================================================
# Part 5 -- controls on the gate's OWN anti-vacuity self-test.
#
# The gate proves its patterns still match before trusting a zero from them,
# which is the guard that stops it passing every APK after a pattern typo
# (#579). A guard nobody has watched fire is worth what an unfired one is, so
# each is made to fire against a deliberately broken copy of the gate.
# ===========================================================================
broken_gate=$sandbox/broken-gate.sh
# mutate <sed-program> -- rebuild the broken copy, and require the edit to have
# taken. A mutation that quietly matched nothing would leave the copy identical
# to the gate, and the control would then "pass" while measuring nothing.
mutate() {
cp "$gate" "$broken_gate"
sed -i "$1" "$broken_gate"
set +e
cmp -s "$gate" "$broken_gate"
local same=$?
set -e
if [ "$same" -eq 0 ]; then
echo "FAIL: the mutation '$1' did not change the gate -- the control below would be vacuous"
failures=$((failures + 1))
return
fi
echo "ok: mutation '$1' took"
}
# A dex pattern that no longer matches its own sample. Every APK would score
# zero against it, so every APK would look clean.
stage_clean
seal selftest.apk
mutate "s|^ 'com/google/firebase'$| 'com/google/firebase/NOPE'|"
set +e
bash "$broken_gate" --apk "$apk" --expect foss > "$out_file" 2>&1
run_status=$?
set -e
run_ok=1
if [ "$run_status" -eq 0 ]; then
run_ok=0
fail_case 'a dex pattern that matches nothing is caught by the self-test -- gate exited 0'
fi
must_say 'a dex pattern that matches nothing is caught by the self-test' \
"self-test: dex pattern 'com/google/firebase/NOPE' matched nothing"
# The same for the non-dex scope.
# Ranged to the FIRST match on purpose: `'google_app_id'` appears twice in the
# gate, once as a pattern and once as its own sample, and rewriting both leaves
# the pair still matching -- a mutation that looks applied and measures nothing.
# META_PATTERNS precedes META_SAMPLES, so the first is the pattern.
mutate "0,/^ 'google_app_id'\$/s// 'google_app_id_NOPE'/"
set +e
bash "$broken_gate" --apk "$apk" --expect foss > "$out_file" 2>&1
run_status=$?
set -e
run_ok=1
if [ "$run_status" -eq 0 ]; then
run_ok=0
fail_case 'a non-dex pattern that matches nothing is caught by the self-test -- gate exited 0'
fi
must_say 'a non-dex pattern that matches nothing is caught by the self-test' \
'self-test: non-dex pattern'
# A pattern added without a sample. The arrays are compared by length before
# anything is scanned, so an unexercised pattern stops the gate instead of
# riding along untested.
mutate "s|^ 'com/google/android/gms'$| 'com/google/android/gms'\n 'com/google/ads'|"
set +e
bash "$broken_gate" --apk "$apk" --expect foss > "$out_file" 2>&1
run_status=$?
set -e
run_ok=1
if [ "$run_status" -eq 0 ]; then
run_ok=0
fail_case 'a pattern with no sample is caught -- gate exited 0'
fi
must_say 'a pattern with no sample is caught' \
'every pattern needs a sample that exercises it'
if [ "$failures" -gt 0 ]; then
echo "$failures test(s) failed"
exit 1
fi
echo "All check-apk-distribution.sh tests passed"

View file

@ -0,0 +1,323 @@
#!/usr/bin/env bash
# Regression tests for scripts/check-gate-selftests.sh (#705).
#
# The gate this covers is the mechanical form of the rule "every gate ships a
# self-test", so shipping it without one of its own would be the joke writing
# itself. More usefully: it is a COVERAGE check, and a coverage check is the
# easiest kind to make vacuous -- discover nothing, compare nothing, report
# clean. #629's coverage gate did exactly that, proving comparisons happened
# rather than that they could detect anything. So the red cases below include
# every way this gate could look at an empty set and call it a pass.
#
# Each case builds a throwaway git repo and runs the REAL gate against it via
# its root argument. Nothing is planted in this repo.
set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
gate=$repo_root/scripts/check-gate-selftests.sh
sandbox=$(mktemp -d)
trap 'rm -rf "$sandbox"' EXIT
failures=0
out_file=$sandbox/gate-output.txt
box=$sandbox/repo
run_status=0
run_ok=0
has_verdict=
has_status=0
# `grep` over a FILE, never `printf ... | grep -q` -- see #716/#725: that
# pipeline cannot tell "no match" from "grep could not answer" from "grep
# matched early and `pipefail` reported the writer's SIGPIPE". No pipe, no
# writer, no ambiguity.
out_has() {
set +e
grep -qF -e "$1" "$out_file"
has_status=$?
set -e
case "$has_status" in
0) has_verdict=yes ;;
1) has_verdict=no ;;
*) has_verdict=broken ;;
esac
}
fail_case() {
echo "FAIL: $*"
sed 's/^/ /' "$out_file"
failures=$((failures + 1))
}
# ---------------------------------------------------------------------------
# A repo shaped like this one as far as the gate cares: one gate, its harness,
# and a workflow that runs the harness. Cases then break exactly one thing.
# ---------------------------------------------------------------------------
setup_box() {
rm -rf "$box"
mkdir -p "$box/scripts/tests" "$box/.github/workflows"
git -C "$box" init -q .
git -C "$box" config user.email t@example.com
git -C "$box" config user.name test
printf '#!/usr/bin/env bash\necho "OK: alpha"\n' > "$box/scripts/check-alpha.sh"
printf '#!/usr/bin/env bash\necho "All check-alpha.sh tests passed"\n' \
> "$box/scripts/tests/check-alpha-test.sh"
cat > "$box/.github/workflows/ci.yml" <<'YML'
name: CI
on: [push]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: "alpha gate self-test"
run: bash scripts/tests/check-alpha-test.sh
YML
git -C "$box" add -A
}
# run <want-exit> <label> [root]
run() {
local want=$1 label=$2 where=${3:-$box}
set +e
bash "$gate" "$where" > "$out_file" 2>&1
run_status=$?
set -e
if [ "$run_status" -ne "$want" ]; then
run_ok=0
fail_case "$label -- expected exit $want, got $run_status"
return
fi
run_ok=1
echo "ok: $label (exit $run_status)"
}
# must_say <label> <fixed string> ... -- a red run has to NAME the gap. #678's
# gate exited 1 with zero bytes of output; that is a crash, not a finding.
must_say() {
local label=$1 s
shift
if [ "$run_ok" -ne 1 ]; then
return
fi
for s in "$@"; do
out_has "$s"
if [ "$has_verdict" = broken ]; then
fail_case "$label -- the output read itself failed (grep exited $has_status)"
return
fi
if [ "$has_verdict" = no ]; then
fail_case "$label -- output never contained: $s"
return
fi
done
echo "ok: $label names what it found"
}
must_not_say() {
local label=$1 s
shift
if [ "$run_ok" -ne 1 ]; then
return
fi
for s in "$@"; do
out_has "$s"
if [ "$has_verdict" = broken ]; then
fail_case "$label -- the output read itself failed (grep exited $has_status)"
return
fi
if [ "$has_verdict" = yes ]; then
fail_case "$label -- output should not have contained: $s"
return
fi
done
echo "ok: $label stays silent about $*"
}
# ===========================================================================
# Part 1 -- RED. The two gaps this gate exists to catch.
# ===========================================================================
# The #705 case itself: a gate with no harness. This is the shape `main`
# carried for check-apk-distribution.sh -- the FOSS-purity gate -- until the
# change that added this file.
setup_box
printf '#!/usr/bin/env bash\necho "OK: beta"\n' > "$box/scripts/check-beta.sh"
git -C "$box" add -A
run 1 'a gate with no self-test is caught'
must_say 'a gate with no self-test is caught' \
'GATE SELF-TEST CHECK FAILED (issue #705)' \
'1 gate(s) ship with no self-test:' \
'scripts/check-beta.sh -> scripts/tests/check-beta-test.sh' \
'NONE check-beta'
must_not_say 'a gate with no self-test is caught' 'OK: all '
# The other half: a harness that exists but that no workflow runs. A self-test
# nobody executes is a file, not a gate -- the covered gate keeps passing CI
# while the thing that would notice a lie never runs.
setup_box
printf '#!/usr/bin/env bash\necho "OK: gamma"\n' > "$box/scripts/check-gamma.sh"
printf '#!/usr/bin/env bash\necho "ok"\n' > "$box/scripts/tests/check-gamma-test.sh"
git -C "$box" add -A
run 1 'a self-test no workflow runs is caught'
must_say 'a self-test no workflow runs is caught' \
'1 self-test(s) are not invoked by any workflow:' \
'scripts/tests/check-gamma-test.sh' \
'UNWIRED check-gamma-test.sh'
# Both gaps at once must both be reported. A gate that stops at the first turns
# a cleanup into N round trips.
setup_box
printf '#!/usr/bin/env bash\necho "OK: beta"\n' > "$box/scripts/check-beta.sh"
printf '#!/usr/bin/env bash\necho "OK: gamma"\n' > "$box/scripts/check-gamma.sh"
printf '#!/usr/bin/env bash\necho "ok"\n' > "$box/scripts/tests/check-gamma-test.sh"
git -C "$box" add -A
run 1 'both kinds of gap are reported together'
must_say 'both kinds of gap are reported together' \
'scripts/check-beta.sh -> scripts/tests/check-beta-test.sh' \
'scripts/tests/check-gamma-test.sh'
# An UNTRACKED harness does not count. It satisfies `[[ -f ]]` on the author's
# machine and does not exist in CI's checkout, and the whole point of this gate
# is what CI will find. Pinned because the obvious implementation gets this
# wrong in the direction that reports a pass.
setup_box
printf '#!/usr/bin/env bash\necho "OK: delta"\n' > "$box/scripts/check-delta.sh"
git -C "$box" add -A
printf '#!/usr/bin/env bash\necho "ok"\n' > "$box/scripts/tests/check-delta-test.sh"
run 1 'an untracked harness does not count'
must_say 'an untracked harness does not count' \
'scripts/check-delta.sh -> scripts/tests/check-delta-test.sh'
# ===========================================================================
# Part 2 -- RED on the discovery itself. Every one of these is a way to look at
# nothing and call it clean, which is the failure this whole class of gate has
# shipped before (#547, #579, #629).
# ===========================================================================
empty_repo=$sandbox/empty-repo
rm -rf "$empty_repo"
mkdir -p "$empty_repo"
git -C "$empty_repo" init -q .
git -C "$empty_repo" config user.email t@example.com
git -C "$empty_repo" config user.name test
printf 'x\n' > "$empty_repo/README.md"
git -C "$empty_repo" add -A
run 1 'a repo with no gates fails rather than passing vacuously' "$empty_repo"
must_say 'a repo with no gates fails rather than passing vacuously' \
'no CI gates found' \
'vacuously'
# Gates but no harnesses at all. Caught by the discovery guard before the
# per-gate loop, so the message is about the empty set and not about one gate.
no_tests=$sandbox/no-tests
rm -rf "$no_tests"
mkdir -p "$no_tests/scripts" "$no_tests/.github/workflows"
git -C "$no_tests" init -q .
git -C "$no_tests" config user.email t@example.com
git -C "$no_tests" config user.name test
printf '#!/usr/bin/env bash\necho hi\n' > "$no_tests/scripts/check-alpha.sh"
printf 'name: CI\non: [push]\n' > "$no_tests/.github/workflows/ci.yml"
git -C "$no_tests" add -A
run 1 'a repo with no self-tests at all fails' "$no_tests"
must_say 'a repo with no self-tests at all fails' 'no gate self-tests found'
# No workflow files. Without this guard, every harness would be "unwired" --
# which is red, but for the wrong reason and with a misleading message. The
# guard says what is actually wrong.
no_wf=$sandbox/no-workflows
rm -rf "$no_wf"
mkdir -p "$no_wf/scripts/tests"
git -C "$no_wf" init -q .
git -C "$no_wf" config user.email t@example.com
git -C "$no_wf" config user.name test
printf '#!/usr/bin/env bash\necho hi\n' > "$no_wf/scripts/check-alpha.sh"
printf '#!/usr/bin/env bash\necho ok\n' > "$no_wf/scripts/tests/check-alpha-test.sh"
git -C "$no_wf" add -A
run 1 'a repo with no workflow files fails' "$no_wf"
must_say 'a repo with no workflow files fails' 'no workflow files found'
# Outside a work tree, `git ls-files` finds nothing and everything would pass.
not_a_repo=$sandbox/not-a-repo
rm -rf "$not_a_repo"
mkdir -p "$not_a_repo"
run 1 'a directory that is not a work tree fails' "$not_a_repo"
must_say 'a directory that is not a work tree fails' 'is not a git work tree'
run 1 'a root that does not exist fails' "$sandbox/nowhere"
must_say 'a root that does not exist fails' 'no such directory'
# ===========================================================================
# Part 3 -- GREEN. Including the boundaries of the name-based predicate, which
# are a deliberate choice and so are asserted rather than left to the comment
# that explains them.
# ===========================================================================
setup_box
run 0 'a fully covered repo passes'
must_say 'a fully covered repo passes' \
'OK: all 1 gate(s) have a self-test' \
'WIRED check-alpha-test.sh'
# `scripts/backfill-relay-secrets.sh` is the real instance of this: not named
# `check-*`, not a gate, and nothing here forces it to have a self-test. The
# predicate is name-based on purpose (#658 raises the general objection); the
# miss direction is safe, so it is pinned as intended behaviour.
setup_box
printf '#!/usr/bin/env bash\necho "not a gate"\n' > "$box/scripts/backfill-relay-secrets.sh"
git -C "$box" add -A
run 0 'a non-check-* script is outside the gate set'
# The flip side of the same boundary, and the reason the wiring half is checked
# over ALL harnesses rather than only the ones belonging to a gate: a harness
# for a non-`check-` subject still has to be RUN. (`backfill-relay-secrets-test.sh`
# and `gate-filters-test.sh` are the live instances.)
setup_box
printf '#!/usr/bin/env bash\necho "not a gate"\n' > "$box/scripts/backfill-relay-secrets.sh"
printf '#!/usr/bin/env bash\necho ok\n' > "$box/scripts/tests/backfill-relay-secrets-test.sh"
git -C "$box" add -A
run 1 'a harness for a non-gate subject still has to be wired'
must_say 'a harness for a non-gate subject still has to be wired' \
'scripts/tests/backfill-relay-secrets-test.sh'
setup_box
printf '#!/usr/bin/env bash\necho "not a gate"\n' > "$box/scripts/backfill-relay-secrets.sh"
printf '#!/usr/bin/env bash\necho ok\n' > "$box/scripts/tests/backfill-relay-secrets-test.sh"
cat >> "$box/.github/workflows/ci.yml" <<'YML'
- name: "backfill self-test"
run: bash scripts/tests/backfill-relay-secrets-test.sh
YML
git -C "$box" add -A
run 0 'a wired harness for a non-gate subject passes'
# Workflow discovery must cover `.yaml` as well as `.yml`; a harness wired only
# from a `.yaml` file is wired.
setup_box
printf '#!/usr/bin/env bash\necho "OK: eps"\n' > "$box/scripts/check-epsilon.sh"
printf '#!/usr/bin/env bash\necho ok\n' > "$box/scripts/tests/check-epsilon-test.sh"
cat > "$box/.github/workflows/extra.yaml" <<'YML'
name: Extra
on: [push]
jobs:
x:
runs-on: ubuntu-latest
steps:
- run: bash scripts/tests/check-epsilon-test.sh
YML
git -C "$box" add -A
run 0 'a harness wired from a .yaml workflow counts'
# ===========================================================================
# Part 4 -- the real repo. The gate's answer about the tree it actually guards,
# asserted here as well as in its own CI step: a suite that only ever runs
# against fixtures can drift into agreeing with a gate that has stopped
# describing this repo.
# ===========================================================================
run 0 'this repository passes' "$repo_root"
must_say 'this repository passes' 'OK: all '
if [ "$failures" -gt 0 ]; then
echo "$failures test(s) failed"
exit 1
fi
echo "All check-gate-selftests.sh tests passed"

View file

@ -0,0 +1,467 @@
#!/usr/bin/env bash
# Regression tests for scripts/check-zombie-screens.sh (#705, #657).
#
# WHY THIS FILE EXISTS
# --------------------
# This gate stops an edit landing in a dead file, and it had shipped with no
# self-test at all. #657 already established that it CAN produce a well-formed
# lie: with a deliberately broken `awk` the pre-#657 form printed
# "OK: no zombie screens" and exited 0 over a tree containing one, because a
# process substitution reports no status and an unreadable file is
# indistinguishable from a file that declares nothing. That red case is known,
# so it is pinned here rather than described in a comment (Part 3).
#
# The gate reads a hardcoded RELATIVE path (`app/src/main/...`), so every case
# runs it with the working directory inside a throwaway tree built here. No
# probe files are ever planted in the real repo.
set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
gate=$repo_root/scripts/check-zombie-screens.sh
sandbox=$(mktemp -d)
trap 'rm -rf "$sandbox"' EXIT
failures=0
out_file=$sandbox/gate-output.txt
tree=$sandbox/tree
screens=app/src/main/java/com/rousecontext/app/ui/screens
prod=app/src/main/java/com/rousecontext/app/ui
run_status=0
run_ok=0
has_verdict=
has_status=0
# ---------------------------------------------------------------------------
# Reading the gate's output.
#
# `grep` over a FILE, never `printf ... | grep -q` (#716/#725): that pipeline
# folds three different outcomes into one answer -- grep found nothing, grep
# could not answer, and grep matched EARLY and killed the writer, whose SIGPIPE
# `pipefail` then reports as the pipeline's status for `!` to invert into "no
# match". With no pipe there is no writer to kill, and grep's own status is
# read, so "could not answer" is reported as its own thing instead of as
# "absent".
# ---------------------------------------------------------------------------
out_has() {
set +e
grep -qF -e "$1" "$out_file"
has_status=$?
set -e
case "$has_status" in
0) has_verdict=yes ;;
1) has_verdict=no ;;
*) has_verdict=broken ;;
esac
}
fail_case() {
echo "FAIL: $*"
sed 's/^/ /' "$out_file"
failures=$((failures + 1))
}
# ---------------------------------------------------------------------------
# The throwaway tree. `reset_tree` gives an app with one screen already wired
# into navigation, so a case that plants a zombie is measuring the zombie and
# not an empty tree -- the same reason the APK harness stages app code before
# adding markers.
# ---------------------------------------------------------------------------
reset_tree() {
rm -rf "$tree"
mkdir -p "$tree/$screens" "$tree/$prod"
cat > "$tree/$screens/HomeScreen.kt" <<'KT'
package com.rousecontext.app.ui.screens
import androidx.compose.runtime.Composable
@Composable
fun HomeScreen() {
}
KT
cat > "$tree/$prod/AppNavigation.kt" <<'KT'
package com.rousecontext.app.ui
import com.rousecontext.app.ui.screens.HomeScreen
fun AppNavigation() {
HomeScreen()
}
KT
}
# screen <file-name> <body>
screen() {
printf '%s\n' "$2" > "$tree/$screens/$1"
}
# prod <relative-path> <body>
prod_file() {
mkdir -p "$(dirname "$tree/$prod/$1")"
printf '%s\n' "$2" > "$tree/$prod/$1"
}
# run <want-exit> <label> [gate-path] -- runs the gate from inside the tree.
run() {
local want=$1 label=$2 which=${3:-$gate}
set +e
( cd "$tree" && bash "$which" ) > "$out_file" 2>&1
run_status=$?
set -e
if [ "$run_status" -ne "$want" ]; then
run_ok=0
fail_case "$label -- expected exit $want, got $run_status"
return
fi
run_ok=1
echo "ok: $label (exit $run_status)"
}
# must_say <label> <fixed string> ...
#
# Exiting non-zero is not the bar. #678's step-order gate exited 1 with zero
# bytes of output on the two inputs it existed to catch -- a crash that reads
# as a finding. A red run has to name the file, and the function, or nobody can
# act on it.
must_say() {
local label=$1 s
shift
if [ "$run_ok" -ne 1 ]; then
return
fi
for s in "$@"; do
out_has "$s"
if [ "$has_verdict" = broken ]; then
fail_case "$label -- the output read itself failed (grep exited $has_status)"
return
fi
if [ "$has_verdict" = no ]; then
fail_case "$label -- output never contained: $s"
return
fi
done
echo "ok: $label names what it found"
}
# must_not_say <label> <fixed string> ...
must_not_say() {
local label=$1 s
shift
if [ "$run_ok" -ne 1 ]; then
return
fi
for s in "$@"; do
out_has "$s"
if [ "$has_verdict" = broken ]; then
fail_case "$label -- the output read itself failed (grep exited $has_status)"
return
fi
if [ "$has_verdict" = yes ]; then
fail_case "$label -- output should not have contained: $s"
return
fi
done
echo "ok: $label stays silent about $*"
}
zombie_kt='package com.rousecontext.app.ui.screens
import androidx.compose.runtime.Composable
@Composable
fun GhostScreen() {
}'
# ===========================================================================
# Part 1 -- RED. A tree containing the thing the gate exists to catch.
# ===========================================================================
reset_tree
screen GhostScreen.kt "$zombie_kt"
run 1 'an unreferenced screen is caught'
must_say 'an unreferenced screen is caught' \
'zombie screen files detected' \
"$screens/GhostScreen.kt" \
'(functions: GhostScreen)'
must_not_say 'an unreferenced screen is caught' 'OK: no zombie screens'
# A file that references only ITSELF is still dead. The gate excludes the file
# under test from its own reference search; without that, every screen would
# vouch for itself and the gate would pass everything.
reset_tree
screen SelfScreen.kt 'package com.rousecontext.app.ui.screens
import androidx.compose.runtime.Composable
@Composable
fun SelfScreen() {
SelfScreen()
}'
run 1 'a screen that only references itself is caught'
must_say 'a screen that only references itself is caught' \
"$screens/SelfScreen.kt"
# Referenced from a TEST, not from production. `app/src/test` is outside
# PROD_ROOT on purpose: a screen kept alive only by its own test is exactly the
# dead-code shape this gate is for.
reset_tree
screen GhostScreen.kt "$zombie_kt"
mkdir -p "$tree/app/src/test/java/com/rousecontext/app"
printf '%s\n' 'class GhostScreenTest { fun t() { GhostScreen() } }' \
> "$tree/app/src/test/java/com/rousecontext/app/GhostScreenTest.kt"
run 1 'a screen referenced only from a test is caught'
must_say 'a screen referenced only from a test is caught' \
"$screens/GhostScreen.kt"
# Referenced from a non-Kotlin production file. The reference search is
# `--include='*.kt'`, so a name that survives only in a comment in some XML or
# a .pro file is not a live call site.
reset_tree
screen GhostScreen.kt "$zombie_kt"
prod_file navigation.xml '<!-- GhostScreen -->'
run 1 'a name appearing only in a non-Kotlin file does not rescue a screen'
must_say 'a name appearing only in a non-Kotlin file does not rescue a screen' \
"$screens/GhostScreen.kt"
# Word boundaries. `GhostScreenKt` is a different symbol; a reference search
# that matched on substrings would let a stale generated-class name keep a dead
# screen alive.
reset_tree
screen GhostScreen.kt "$zombie_kt"
prod_file Registry.kt 'package com.rousecontext.app.ui
val names = listOf("GhostScreenKt", "MyGhostScreen")'
run 1 'a substring of the name does not count as a reference'
must_say 'a substring of the name does not count as a reference' \
"$screens/GhostScreen.kt"
# Every zombie is listed, not just the first. A gate that stops at one turns a
# cleanup into N round trips.
reset_tree
screen GhostScreen.kt "$zombie_kt"
screen PhantomScreen.kt 'package com.rousecontext.app.ui.screens
import androidx.compose.runtime.Composable
@Composable
fun PhantomScreen() {
}'
run 1 'every zombie is listed'
must_say 'every zombie is listed' \
"$screens/GhostScreen.kt" \
"$screens/PhantomScreen.kt"
# ===========================================================================
# Part 2 -- GREEN. The cases that must NOT fire.
# ===========================================================================
reset_tree
run 0 'a wired-up screen passes'
must_say 'a wired-up screen passes' 'OK: no zombie screens'
# Not every *Screen.kt holds a composable. Enums, sealed classes and mode
# objects live here too, and a gate that flagged them would be worked around.
reset_tree
screen SetupMode.kt 'package com.rousecontext.app.ui.screens
enum class SetupMode { FIRST_RUN, RETURNING }'
run 0 'a file with no top-level composable is skipped'
# `private fun` is not an entry point, so a file whose public composable is
# referenced passes even though its private helpers are named nowhere else.
reset_tree
screen HelperScreen.kt 'package com.rousecontext.app.ui.screens
import androidx.compose.runtime.Composable
@Composable
fun HelperScreen() {
HelperRow()
}
@Composable
private fun HelperRow() {
}'
prod_file Extra.kt 'package com.rousecontext.app.ui
fun show() { HelperScreen() }'
run 0 'private helpers do not have to be referenced'
# ===========================================================================
# Part 3 -- the #657 red case, pinned.
#
# The gate reads its composable names through `awk`, and the pre-#657 form took
# that through a process substitution, which reports NO status: an `awk` that
# failed produced an empty list, indistinguishable from "this file declares no
# composables", and the file was then skipped in silence. #657 measured the
# result -- "OK: no zombie screens", exit 0, over a tree containing one.
#
# Two runs, and both are needed. The first asserts the CURRENT gate fails by
# name when `awk` cannot run. The second reintroduces the old shape and
# requires it to produce exactly the reported lie, which is what proves the
# first assertion is not passing for some unrelated reason -- the tree really
# does hide a zombie the moment the status goes unchecked.
# ===========================================================================
# A PATH shim, so the REAL gate is exercised: nothing about it is modified, it
# simply cannot read the file. Only `awk` is shadowed; the rest of the tools it
# calls resolve normally.
mkdir -p "$sandbox/brokenbin"
printf '#!/bin/sh\nexit 7\n' > "$sandbox/brokenbin/awk"
chmod +x "$sandbox/brokenbin/awk"
run_with_broken_awk() {
local want=$1 label=$2 which=${3:-$gate}
set +e
( cd "$tree" && PATH="$sandbox/brokenbin:$PATH" bash "$which" ) > "$out_file" 2>&1
run_status=$?
set -e
if [ "$run_status" -ne "$want" ]; then
run_ok=0
fail_case "$label -- expected exit $want, got $run_status"
return
fi
run_ok=1
echo "ok: $label (exit $run_status)"
}
reset_tree
screen GhostScreen.kt "$zombie_kt"
run_with_broken_awk 1 'an awk that cannot run is a failure, not a pass'
must_say 'an awk that cannot run is a failure, not a pass' \
'ERROR: could not extract composable names from' \
'The file was not scanned, so this is a failure and not a pass.'
must_not_say 'an awk that cannot run is a failure, not a pass' 'OK: no zombie screens'
# The old shape, reconstructed by replacing the failure branch with a silent
# skip -- which is precisely what a status-free read amounts to.
unchecked_gate=$sandbox/unchecked-gate.sh
sed '/could not extract composable names/,+2c\ func_names=' "$gate" > "$unchecked_gate"
set +e
cmp -s "$gate" "$unchecked_gate"
mutation_took=$?
set -e
if [ "$mutation_took" -eq 0 ]; then
echo "FAIL: reconstructing the pre-#657 shape did not change the gate --" \
"the control below would be vacuous"
failures=$((failures + 1))
else
echo "ok: the pre-#657 shape reconstructs"
# Same tree, same broken awk, status unchecked: the reported lie.
run_with_broken_awk 0 'the pre-#657 shape reports OK over a zombie (#657)' "$unchecked_gate"
must_say 'the pre-#657 shape reports OK over a zombie (#657)' 'OK: no zombie screens'
# And with a working awk it still finds the zombie, so the run above differs
# from the run under it in exactly one thing: whether awk could read the file.
run 1 'the reconstructed shape still catches the zombie when awk works' "$unchecked_gate"
must_say 'the reconstructed shape still catches the zombie when awk works' \
"$screens/GhostScreen.kt"
fi
# ===========================================================================
# Part 4 -- the exemption list. An escape hatch nobody has tested is how a gate
# quietly stops covering things (#579).
# ===========================================================================
exempt_gate=$sandbox/exempt-gate.sh
sed 's/^KNOWN_EXEMPT=()$/KNOWN_EXEMPT=(GhostScreen.kt)/' "$gate" > "$exempt_gate"
set +e
cmp -s "$gate" "$exempt_gate"
exempt_took=$?
set -e
if [ "$exempt_took" -eq 0 ]; then
echo "FAIL: adding an exemption did not change the gate -- the control below would be vacuous"
failures=$((failures + 1))
else
echo "ok: the exemption mutation took"
reset_tree
screen GhostScreen.kt "$zombie_kt"
run 0 'an exempted zombie passes' "$exempt_gate"
# And the exemption is per file, not a blanket switch.
screen PhantomScreen.kt 'package com.rousecontext.app.ui.screens
import androidx.compose.runtime.Composable
@Composable
fun PhantomScreen() {
}'
run 1 'an exemption covers only the file it names' "$exempt_gate"
must_say 'an exemption covers only the file it names' "$screens/PhantomScreen.kt"
must_not_say 'an exemption covers only the file it names' 'GhostScreen.kt'
fi
# ===========================================================================
# Part 5 -- residues. Measured behaviours that are NOT what a reader would
# assume. Asserted rather than left implicit: if someone changes one of these,
# it should be a decision with a failing test in front of it.
# ===========================================================================
# 5a. GREEN BY ABSENCE -- tracked as #758. With no screens directory the glob
# does not expand, the loop body never runs, and the gate prints OK and exits
# 0. It is latent only because android-ci.yml happens to invoke it from the
# repo root. When #758 lands, this assertion flips to `run 1` plus a message
# check; it is here so the hole is measured rather than assumed absent.
rm -rf "$tree"
mkdir -p "$tree"
run 0 'residue (#758): a missing screens directory reports OK'
must_say 'residue (#758): a missing screens directory reports OK' 'OK: no zombie screens'
# 5b. The check is per FILE, not per function: one referenced composable
# vouches for every other public composable in the same file. Coarse by
# design -- the gate's own header says so -- but worth pinning, because it is
# the difference between "no dead screens" and "no dead composables".
reset_tree
screen MixedScreen.kt 'package com.rousecontext.app.ui.screens
import androidx.compose.runtime.Composable
@Composable
fun MixedScreen() {
}
@Composable
fun MixedScreenDetail() {
}'
prod_file Extra.kt 'package com.rousecontext.app.ui
fun show() { MixedScreen() }'
run 0 'residue: one live composable vouches for the whole file'
# 5c. `@Preview` skips the ANNOTATION line, not the function under it. Only
# `private fun` is dropped, so a PUBLIC preview composable counts as an entry
# point and an unreferenced one is reported as a zombie. The gate's header says
# previews "are ignored", which holds for the private ones it describes and not
# for this shape.
reset_tree
screen PreviewOnlyScreen.kt 'package com.rousecontext.app.ui.screens
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Preview
@Composable
fun PreviewOnlyScreen() {
}'
run 1 'residue: a PUBLIC @Preview composable is not exempt'
must_say 'residue: a PUBLIC @Preview composable is not exempt' \
"$screens/PreviewOnlyScreen.kt"
# And the private form the header actually describes: dropped, so a file
# holding nothing else is skipped entirely.
reset_tree
screen PrivatePreviewScreen.kt 'package com.rousecontext.app.ui.screens
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Preview
@Composable
private fun PrivatePreviewScreen() {
}'
run 0 'a private @Preview composable is skipped'
if [ "$failures" -gt 0 ]; then
echo "$failures test(s) failed"
exit 1
fi
echo "All check-zombie-screens.sh tests passed"