Files
callstack__agent-device/.github/workflows/test-app-build-cache.yml
Michał Pierzchała 02d548dfc9 ci: consolidate CI workflow from 15 jobs to 8 (#1996)
* ci: consolidate CI workflow from 15 jobs to 8

Merge single-gate ubuntu jobs into grouped jobs sharing one checkout and
install: Lint & Format (plus the static text assertions), Repo Guards
(layering/selector/wiring/maestro/mcp-metadata), Compatibility &
Provenance (shared fetch-depth: 0 checkout), Typecheck & Package, and
Integration Tests (absorbs the web smoke with step-scoped env). Every
gate remains an independently named run-gate step; the gate manifest
derives lane ownership structurally.

Drop the Bun setup from FreeRange: @chenglou/freerange's bin is a plain
Node script. It stays GitHub-owned; only the runtime requirement is
retired.

* ci: fold FreeRange into Repo Guards and skip no-op fixture release jobs

FreeRange runs on plain Node now, so its gate joins Repo Guards as the
last step instead of occupying its own worker for the slowest guard.
The fixture release matrix filters to entries that will actually build,
so a cached-fingerprint PR starts zero release runners.

* ci: fold host XCTests into the macOS smoke lane and shard Coverage

The macOS lane now builds one unit-test-flagged runner bundle that both
the host XCTest run and the replay smoke consume, so the host lane no
longer occupies its own macos-26 runner behind a separate queue. The
host lane's file moves with it, and check:xctest-selection follows.

Coverage shards across two runners via blob reports and merges them on
a report job that evaluates thresholds once over the full suite and
produces every coverage artifact. The tmpdir leak check runs per shard,
since a leak lands on whichever runner executed the file.

* ci: drop local shard-smoke artifacts from tracking

* ci: enforce coverage thresholds only on the merged run

A shard evaluates its own half-suite coverage, so the global gate fired
per shard. Shards now report without gating; Coverage Report keeps the
real thresholds over the full merged suite.

* ci: include hidden files when uploading coverage blobs
2026-08-24 16:42:43 +02:00

233 lines
9.8 KiB
YAML

name: Test App Build Cache
# Builds a Release binary of examples/test-app and publishes it as a workflow
# artifact keyed by the Expo fingerprint, so CI e2e jobs install a ready binary
# (via .github/actions/setup-fixture-app) instead of compiling from scratch.
#
# Release, not dev-client: the JS bundle is embedded, so a consuming job needs no
# Metro. Its JS is refreshed on retrieval with @expo/repack-app, so keying on the
# native-only fingerprint is correct -- a JS-only change reuses the same native
# binary. Local development does not use this; it caches on disk via the
# expo-build-disk-cache provider in app.config.js.
#
# Only builds when the fingerprint has no trusted artifact yet. Runs on every
# push to main and every PR so a same-head consumer can wait for a scheduled
# producer (workflow_dispatch cannot reach a workflow that is not yet on main).
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
# Look up whether this fingerprint's artifact already exists across runs.
actions: read
jobs:
fingerprint:
name: Resolve native fingerprint
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
matrix: ${{ steps.fingerprint.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
with:
cache-dependency-path: examples/test-app/pnpm-lock.yaml
- name: Setup test app dependencies
uses: ./.github/actions/setup-test-app-dependencies
- name: Typecheck test app
uses: ./.github/actions/run-gate
with: { gate: test-app-typecheck }
- name: Resolve native fingerprint
id: fingerprint
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
ARTIFACT_NAME_RESOLVER=".github/actions/setup-fixture-app/resolve-artifact-name.sh"
IOS_NAME="$(sh "$ARTIFACT_NAME_RESOLVER" ios)"
ANDROID_NAME="$(sh "$ARTIFACT_NAME_RESOLVER" android)"
EXPECTED_HEAD_SHA="${{ github.event.pull_request.head.sha || github.sha }}"
TRUSTED_ARTIFACT=".github/actions/setup-fixture-app/trusted-artifact.mjs"
IOS_ARTIFACT="$(node "$TRUSTED_ARTIFACT" find "${{ github.repository }}" "$IOS_NAME" "$EXPECTED_HEAD_SHA")"
ANDROID_ARTIFACT="$(node "$TRUSTED_ARTIFACT" find "${{ github.repository }}" "$ANDROID_NAME" "$EXPECTED_HEAD_SHA")"
IOS_BUILD=true
if [ -n "$IOS_ARTIFACT" ] ||
{ [ "${{ github.event_name }}" = "pull_request" ] &&
[ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; }; then
IOS_BUILD=false
fi
ANDROID_BUILD=true
if [ -n "$ANDROID_ARTIFACT" ]; then
ANDROID_BUILD=false
fi
MATRIX="$(jq -cn \
--arg iosName "$IOS_NAME" \
--argjson iosBuild "$IOS_BUILD" \
--arg androidName "$ANDROID_NAME" \
--argjson androidBuild "$ANDROID_BUILD" \
'[
{
name: "iOS Release",
platform: "ios",
runsOn: (if $iosBuild then "macos-26" else "ubuntu-latest" end),
artifactName: $iosName,
build: $iosBuild
},
{
name: "Android Release",
platform: "android",
runsOn: "ubuntu-latest",
artifactName: $androidName,
build: $androidBuild
}
] | { include: map(select(.build)) }')"
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
release:
name: ${{ matrix.name }}
needs: fingerprint
runs-on: ${{ matrix.runsOn }}
timeout-minutes: 60
concurrency:
group: test-app-${{ matrix.artifactName }}-${{ github.event.pull_request.number || github.ref_name }}
cancel-in-progress: false
strategy:
# Independent caches; one platform failing must not withhold the other's.
fail-fast: false
matrix: ${{ fromJSON(needs.fingerprint.outputs.matrix) }}
steps:
- name: Checkout
if: matrix.build
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup toolchain
if: matrix.build
uses: ./.github/actions/setup-node-pnpm
with:
cache-dependency-path: |
pnpm-lock.yaml
examples/test-app/pnpm-lock.yaml
- name: Setup test app dependencies
if: matrix.build
uses: ./.github/actions/setup-test-app-dependencies
# The generated Android project is safe to assemble without an emulator.
# Keep Gradle's heavy dependency/transforms cache, but never let an
# untrusted fork PR write it.
- name: Setup Gradle cache
if: matrix.platform == 'android' && matrix.build
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
with:
cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}
# `--device generic` builds for the simulator without booting one or
# installing. Release embeds the bundle (no Metro) and links a universal
# x86_64+arm64 slice, so the artifact runs on any developer's simulator.
# Fork PRs cannot safely publish this binary; their iOS workflow builds the
# same Release app inline on a cache miss, so compiling it here would double
# the macOS cost without adding signal.
- name: Build the iOS Release app
if: |
matrix.platform == 'ios' &&
matrix.build &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
run: |
set -euo pipefail
pnpm --dir examples/test-app exec expo run:ios \
--configuration Release \
--device generic \
--no-bundler \
--output "${{ github.workspace }}/.tmp/test-app-build"
# Deliberately no `set -o pipefail`: `yes` is killed by SIGPIPE once
# sdkmanager stops reading, and pipefail would report that 141 as the
# step's own failure.
- name: Install Android SDK packages
if: matrix.platform == 'android' && matrix.build
run: |
SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$SDKMANAGER" ]; then
SDKMANAGER="$SDK_ROOT/cmdline-tools/bin/sdkmanager"
fi
if [ ! -x "$SDKMANAGER" ]; then
echo "sdkmanager not found under $SDK_ROOT" >&2
exit 1
fi
yes | "$SDKMANAGER" --licenses >/dev/null
"$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0"
# CNG's generated Android project exposes the ordinary Gradle Release task;
# it builds and packages every ABI without device resolution. Installation
# and launch are deliberately proven by the Android consumer instead.
- name: Build the Android Release apk
if: matrix.platform == 'android' && matrix.build
run: |
set -euo pipefail
pnpm --dir examples/test-app exec expo prebuild --platform android --no-install
(
cd examples/test-app/android
./gradlew :app:assembleRelease
)
mkdir -p "${{ github.workspace }}/.tmp/test-app-build"
cp examples/test-app/android/app/build/outputs/apk/release/app-release.apk \
"${{ github.workspace }}/.tmp/test-app-build/app-release.apk"
- name: Stage the binary for upload
id: stage
if: |
matrix.build &&
(matrix.platform == 'android' ||
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
run: |
set -euo pipefail
SRC="${{ github.workspace }}/.tmp/test-app-build"
BINARY="$(find "$SRC" -maxdepth 1 \( -name '*.app' -o -name '*.apk' \) | head -1)"
if [ -z "$BINARY" ]; then
echo "::error::Build produced no .app or .apk under $SRC."
exit 1
fi
# An APK must span both the maintainer's arm64 and CI's x86_64; a
# regression here (e.g. an unexpected abiFilter) is otherwise silent.
if [ "${{ matrix.platform }}" = "android" ] && ! unzip -l "$BINARY" | grep -q "lib/arm64-v8a/"; then
echo "::error::Release APK has no arm64-v8a libraries."
exit 1
fi
# Tar the binary: artifact zips drop the executable bit, which would
# leave an installed .app unable to launch.
mkdir -p .tmp/test-app-artifact
tar -czf .tmp/test-app-artifact/binary.tar.gz -C "$SRC" "$(basename "$BINARY")"
echo "publishing ${{ matrix.artifactName }} ($(du -h .tmp/test-app-artifact/binary.tar.gz | cut -f1))"
# Fork PRs run with a read-only token but can still upload artifacts, and a
# consumer serves whatever it finds by name -- so a fork could publish a
# tampered binary that CI then executes. Only branches in this repo publish.
# Android still builds on forks; iOS gets equivalent build/install signal
# from the smoke workflow's inline cache-miss fallback.
- name: Publish to the build cache
if: |
matrix.build &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ${{ matrix.artifactName }}
path: .tmp/test-app-artifact/binary.tar.gz
if-no-files-found: error
compression-level: 0 # already gzipped