# T4: the Mac is in the merge path. Engine, client, obfs4 and app unit suites on # the project's own runner (tag mac-local). Instrumented tests stay manual: # they need an emulator and a local relay. stages: [test] # The repository is public, so this sweep is the last thing between a pasted # key and the internet. Until 2026-09-17 it only ran when somebody remembered # to, and it had been red for nine days without anyone noticing, because a # formatter carried an allow marker off the line it covered. A check nobody # runs protects nothing; here it runs on every merge request and every push # to main, and it finishes in seconds. secret-sweep: stage: test tags: [mac-local] rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_COMMIT_BRANCH == "main"' variables: GIT_DEPTH: "1" script: - bash scripts/secret-sweep.sh timeout: 5m unit-tests: stage: test tags: [mac-local] rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_COMMIT_BRANCH == "main"' variables: GIT_DEPTH: "1" # The interop tests FAIL rather than skip when unconfigured, on purpose, # a silent skip is how a transport suite goes green while proving nothing. # They need a real lyrebird from the flamenet-elligator2 checkout, which is # not in this build directory, so they are opted out HERE and nowhere else. # Run `scripts/obfs4-interop.sh` locally before merging any obfs4 change: # every other test in that module compares against recorded vectors or # against itself, and two identically-wrong ends agree perfectly. OBFS4_INTEROP_OPTOUT: "1" script: - export JAVA_HOME=/opt/homebrew/opt/openjdk@21 - export ANDROID_HOME=$HOME/Library/Android/sdk # The protocol vectors are not in this repo, and CI never had them. # `AccountKdfTest`, `PQVectorTest` and `PublishedVectorsTest` look for a # sibling `flamenet-e2e` checkout. That exists on a developer's machine and # has never existed in a build directory, so all twenty of them have been # failing here since they were written, invisibly, because the build cache # replayed a local pass. They fail rather than skip when the file is # missing, exactly so this could not hide; it took `--rerun-tasks` to let # them say so. # # Pinned to a commit, NOT cloned at tip. Tip was the first instinct, # "if the protocol changes this SHOULD go red", and flamenet-relay#56 is # the write-up of what that actually costs. There, a job clones # `flamenet-e2e` at HEAD, so merging anything in that repo turned relay's # `main` red in a pipeline nobody was watching, blocked six MRs sitting on # merge-when-pipeline-succeeds, and was misdiagnosed as runner starvation. # It fired twice in one evening. The person who merged was working in a # different project and had no reason to look. # # A pin makes the same divergence a deliberate, reviewable bump here instead # of a surprise failure in a repo nobody was thinking about. The commit # lives in `.e2e-ref`; `.e2e-ref.md` says how to take an update. # # A pin ALONE is worse than HEAD, and that is what the first version # of this got wrong. Pinning silently means the vectors can move and this # suite goes on checking last month's protocol, reporting green, the exact # "tested against a pinned old spec" failure the relay's own note warns # about. `vectors-head` below is the other half: it runs the same checks at # HEAD and is allowed to fail, so being behind is LOUD and non-blocking # while being broken stays a hard stop. # # `flamenet-e2e` is public, so no credentials are needed. - FLAMENET_E2E_PIN=$(cat "$CI_PROJECT_DIR/.e2e-ref") - rm -rf "$CI_PROJECT_DIR/.ci-vectors" - git clone -q https://gitlab.com/paulhitt/flamenet-e2e.git "$CI_PROJECT_DIR/.ci-vectors" - git -C "$CI_PROJECT_DIR/.ci-vectors" checkout -q "$FLAMENET_E2E_PIN" - export FLAMENET_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/protocol-v1.json" - export FLAMENET_PQ_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/pqx3dh-v2.json" - export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/account-kdf-v1.json" - export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-v1.json" - export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/backup-payload-v1.json" - export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors/test/vectors/content-v1.json" # Checked before the suite runs. A path that silently does not exist puts # the tests straight back where they were, and the failure would read as a # protocol bug rather than a missing file. - | for f in "$FLAMENET_VECTORS" "$FLAMENET_PQ_VECTORS" "$FLAMENET_ACCOUNT_VECTORS" \ "$FLAMENET_BACKUP_VECTORS" "$FLAMENET_BACKUP_PAYLOAD_VECTORS" \ "$FLAMENET_CONTENT_VECTORS"; do [ -f "$f" ] || { echo "GATE: vector file missing: $f"; exit 1; } done # Clear old results before running, or the gate counts ghosts. The # runner reuses its build directory, so XML from a previous pipeline # survives, and a suite that stopped running entirely would still be # counted, by the very check meant to notice that it had stopped. - rm -rf ./*/build/test-results # Keep the build output out of Spotlight. # # The module build directories are rewritten every job on a host that is # also the mac-local runner for both app repos. Indexing them drives the # machine to a load average in the hundreds, with `mds_stores` and # `mdworker_shared` at the top of CPU while no build process is near it. # Measured on the iPhone repo: the same from-scratch build peaks at load # ~12 with its output excluded and well over a hundred without. # # Written here rather than committed, because the directories these belong # in are created by the build. - | for m in app engine client obfs4; do mkdir -p "$m/build" && touch "$m/build/.metadata_never_index" done # `:obfs4:test` was missing from this list. The module shipped in !77 # with ~100 tests, the Elligator2 port, the framing, the handshake, the # socket factory, and not one of them ran in CI. A break in the transport # would have merged green. # # `--rerun-tasks` because this runner is the developer's own Mac. # Gradle's build cache is shared with local runs, so a task whose inputs a # local run already covered comes back FROM-CACHE and CI replays a result it # never computed. That is not independent verification, and it is invisible: # a cached pass and a real pass print the same "BUILD SUCCESSFUL". # Both app variants, not just debug. The release variant was never run # here, so four tests that die on it -- the merged manifest has no # ComponentActivity, which is a debug-only dependency -- were red from the # day they landed and CI never said a word. A variant nobody tests is a # variant nobody knows about, and release is the one that ships. - ./gradlew --no-daemon --rerun-tasks :engine:test :client:test :obfs4:test :app:testDebugUnitTest :app:testReleaseUnitTest - | # Assert on the RESULTS, never on Gradle's exit status. `:app` prints # no per-test names at all, so a filter matching nothing and a full green # run are indistinguishable in the log, and a skipped-as-up-to-date task # is likewise just "BUILD SUCCESSFUL". The XML is the only thing that says # what actually executed. python3 - <<'PY' import glob, re, sys # Floors, not exact counts. Set around 90% of the suite as it stands # (663 / 453 / 75 / 572) so a module that loses most of its # tests to a bad filter is caught, without a gate that has to be edited # every time somebody adds one. # # `app` counts both variants, because the glob takes every # `test-results/*` directory and both now run. MINIMUMS = {"engine": 595, "client": 405, "obfs4": 68, "app": 515} totals = {} for module in MINIMUMS: tests = failures = skipped = 0 for path in glob.glob(f"{module}/build/test-results/*/TEST-*.xml"): text = open(path).read() m = re.search(r'tests="(\d+)" skipped="(\d+)" failures="(\d+)" errors="(\d+)"', text) if not m: continue tests += int(m.group(1)); skipped += int(m.group(2)) failures += int(m.group(3)) + int(m.group(4)) totals[module] = (tests, failures, skipped) bad = False for module, (tests, failures, skipped) in totals.items(): print(f"GATE: {module}: {tests} tests, {failures} failures, {skipped} skipped") if tests < MINIMUMS[module]: print(f"GATE: {module} reported {tests} tests, the suite did not run") bad = True if failures: bad = True # A floor across the whole build as well as per module: a module can be # present and still have lost most of its suite to a bad filter. grand = sum(t for t, _, _ in totals.values()) print(f"GATE: {grand} tests in total") if grand < 1580: print(f"GATE: only {grand} tests in the whole build; something is not running") bad = True sys.exit(1 if bad else 0) PY timeout: 30m # The pin's conscience. Runs the vector suites against flamenet-e2e's # actual HEAD, and is allowed to fail. Without it, `.e2e-ref` is permission to # fall behind forever with a green board, which is the failure mode a pin # introduces and the reason the relay pairs its pin with the same job. # # A red run here means an update is waiting. Take it per `.e2e-ref.md`. vectors-head: stage: test tags: [mac-local] allow_failure: true rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_COMMIT_BRANCH == "main"' variables: GIT_DEPTH: "1" OBFS4_INTEROP_OPTOUT: "1" script: - export JAVA_HOME=/opt/homebrew/opt/openjdk@21 - export ANDROID_HOME=$HOME/Library/Android/sdk - rm -rf "$CI_PROJECT_DIR/.ci-vectors-head" - git clone --depth 1 https://gitlab.com/paulhitt/flamenet-e2e.git "$CI_PROJECT_DIR/.ci-vectors-head" - | echo "pinned: $(cat "$CI_PROJECT_DIR/.e2e-ref")" echo "at HEAD: $(git -C "$CI_PROJECT_DIR/.ci-vectors-head" rev-parse HEAD)" - export FLAMENET_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/protocol-v1.json" - export FLAMENET_PQ_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/pqx3dh-v2.json" - export FLAMENET_ACCOUNT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/account-kdf-v1.json" - export FLAMENET_BACKUP_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-v1.json" - export FLAMENET_BACKUP_PAYLOAD_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/backup-payload-v1.json" - export FLAMENET_CONTENT_VECTORS="$CI_PROJECT_DIR/.ci-vectors-head/test/vectors/content-v1.json" # Only the module that reads vectors. The rest of the suite has nothing # to say about whether flamenet-e2e moved, and running it here would double # the board's cost to answer a question it cannot answer. - ./gradlew --no-daemon --rerun-tasks :engine:test timeout: 20m