|
| 1 | +name: Build and Test |
| 2 | + |
| 3 | +on: |
| 4 | + push: |
| 5 | + pull_request: |
| 6 | + |
| 7 | +jobs: |
| 8 | + build-and-test: |
| 9 | + runs-on: ubuntu-latest |
| 10 | + timeout-minutes: 45 |
| 11 | + env: |
| 12 | + WORKSPACE: "${{ github.workspace }}" |
| 13 | + GIT_BRANCH: "${{ github.ref }}" |
| 14 | + CURRENT_TAG: "${{ github.ref_name }}" |
| 15 | + # Worker endpoints: consumed from GitHub organisation variables first. |
| 16 | + # If the org variable is not set the value is an empty string; the probe |
| 17 | + # step below falls back to the same hardcoded defaults that are baked into |
| 18 | + # the mini-sdk (MiniAttesterConfig / Approov.m), keeping them in sync. |
| 19 | + # Worker management (create / redeploy) logic is centralized in the |
| 20 | + # core-service-layers-testing script, but invoked here when needed. |
| 21 | + TESTING_REPLY_URL: ${{ vars.TESTING_REPLY_URL }} |
| 22 | + TESTING_REPLY_URL_UNPROTECTED: ${{ vars.TESTING_REPLY_URL_UNPROTECTED }} |
| 23 | + # Required for the redeploy script invocation on failure |
| 24 | + CLOUDFLARE_API_TOKEN_WORKERS_DEV: ${{ secrets.CLOUDFLARE_API_TOKEN_WORKERS_DEV }} |
| 25 | + CLOUDFLARE_ACCOUNT_ID_WORKERS_DEV: ${{ secrets.CLOUDFLARE_ACCOUNT_ID_WORKERS_DEV }} |
| 26 | + |
| 27 | + steps: |
| 28 | + # ----------------------------------------------------------------------- |
| 29 | + # 1. Checkout this repository |
| 30 | + # ----------------------------------------------------------------------- |
| 31 | + - name: Set up Git |
| 32 | + run: git config --global --add safe.directory '*' |
| 33 | + |
| 34 | + - name: Checkout approov-service-retrofit |
| 35 | + uses: actions/checkout@v5 |
| 36 | + with: |
| 37 | + path: approov-service-retrofit |
| 38 | + |
| 39 | + # ----------------------------------------------------------------------- |
| 40 | + # 2. Clone core-service-layers-testing as a sibling directory |
| 41 | + # settings.gradle expects: ../core-service-layers-testing/... |
| 42 | + # so both repos must sit under the same parent ($GITHUB_WORKSPACE). |
| 43 | + # ----------------------------------------------------------------------- |
| 44 | + - name: Checkout core-service-layers-testing |
| 45 | + uses: actions/checkout@v5 |
| 46 | + with: |
| 47 | + repository: approov/core-service-layers-testing |
| 48 | + token: ${{ secrets.CORE_SERVICE_LAYERS_TESTING_PAT }} |
| 49 | + path: core-service-layers-testing |
| 50 | + |
| 51 | + # ----------------------------------------------------------------------- |
| 52 | + # 3. Verify worker endpoints (with auto-redeploy) |
| 53 | + # |
| 54 | + # URLs are resolved with the following precedence (mirrors the mini-sdk |
| 55 | + # fallback logic in MiniAttesterConfig / Approov.m): |
| 56 | + # 1. GitHub org variable (TESTING_REPLY_URL / _UNPROTECTED) |
| 57 | + # 2. Mini-SDK hardcoded defaults (replay.ivol.workers.dev / ...) |
| 58 | + # |
| 59 | + # If the workers are unavailable, this step invokes the management |
| 60 | + # script in core-service-layers-testing to redeploy them. |
| 61 | + # ----------------------------------------------------------------------- |
| 62 | + - name: Verify worker endpoints |
| 63 | + run: | |
| 64 | + # ── URL resolution ────────────────────────────────────────────────── |
| 65 | + PROTECTED_URL="${TESTING_REPLY_URL:-https://replay.ivol.workers.dev}" |
| 66 | + UNPROTECTED_URL="${TESTING_REPLY_URL_UNPROTECTED:-https://replay-unprotected.ivol.workers.dev}" |
| 67 | + |
| 68 | + echo "TESTING_REPLY_URL=$PROTECTED_URL" >> "$GITHUB_ENV" |
| 69 | + echo "TESTING_REPLY_URL_UNPROTECTED=$UNPROTECTED_URL" >> "$GITHUB_ENV" |
| 70 | +
|
| 71 | + # ── Probe function ────────────────────────────────────────────────── |
| 72 | + probe_worker() { |
| 73 | + local url="$1" |
| 74 | + curl --silent --show-error --fail --max-time 10 \ |
| 75 | + -X POST "$url" \ |
| 76 | + -H "Content-Type: application/json" \ |
| 77 | + -d '{"check":"probe"}' | grep -q '"body"' |
| 78 | + } |
| 79 | +
|
| 80 | + echo "==> Probing workers..." |
| 81 | + if probe_worker "$PROTECTED_URL" && probe_worker "$UNPROTECTED_URL"; then |
| 82 | + echo " All workers are healthy." |
| 83 | + exit 0 |
| 84 | + fi |
| 85 | +
|
| 86 | + echo " WARNING: One or more workers are down. Attempting redeploy..." |
| 87 | + |
| 88 | + # ── Invoke redeploy script ────────────────────────────────────────── |
| 89 | + # The script is in the sibling directory cloned in Step 2. |
| 90 | + # It uses the CLOUDFLARE_* env variables defined at the job level. |
| 91 | + ./core-service-layers-testing/cloudflare-workers/redeploy-workers.sh |
| 92 | +
|
| 93 | + echo "==> Verifying after redeploy..." |
| 94 | + # Try up to 3 times with a 5s delay between attempts |
| 95 | + for i in {1..3}; do |
| 96 | + if probe_worker "$PROTECTED_URL" && probe_worker "$UNPROTECTED_URL"; then |
| 97 | + echo " Workers successfully restored." |
| 98 | + exit 0 |
| 99 | + fi |
| 100 | + echo " Waiting for propagation (attempt $i/3)..." |
| 101 | + sleep 5 |
| 102 | + done |
| 103 | +
|
| 104 | + echo "ERROR: Workers are still unreachable after redeployment effort." |
| 105 | + exit 1 |
| 106 | +
|
| 107 | + # ----------------------------------------------------------------------- |
| 108 | + # 4. Java / Android toolchain setup |
| 109 | + # ----------------------------------------------------------------------- |
| 110 | + - name: Set Up Java |
| 111 | + uses: actions/setup-java@v5 |
| 112 | + with: |
| 113 | + distribution: 'temurin' |
| 114 | + java-version: '21' |
| 115 | + |
| 116 | + - name: Install Android SDK command-line tools |
| 117 | + run: | |
| 118 | + sudo apt-get update -q |
| 119 | + sudo apt-get install -y -q unzip curl |
| 120 | + mkdir -p "$ANDROID_HOME/cmdline-tools" |
| 121 | + curl -o android-sdk.zip \ |
| 122 | + https://dl.google.com/android/repository/commandlinetools-linux-9123335_latest.zip |
| 123 | + unzip -q android-sdk.zip -d "$ANDROID_HOME/cmdline-tools" |
| 124 | + mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/tools" |
| 125 | + rm android-sdk.zip |
| 126 | + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" |
| 127 | + echo "$ANDROID_HOME/cmdline-tools/tools/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator" >> "$GITHUB_PATH" |
| 128 | +
|
| 129 | + - name: Accept Android SDK licenses |
| 130 | + run: yes | sdkmanager --licenses || true |
| 131 | + |
| 132 | + - name: Install required Android SDK packages |
| 133 | + run: | |
| 134 | + sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" |
| 135 | +
|
| 136 | + # ----------------------------------------------------------------------- |
| 137 | + # 5. Build and run tests |
| 138 | + # Gradle is invoked from inside the checked-out service directory. |
| 139 | + # The mini-sdk is already available via the sibling path wired into |
| 140 | + # settings.gradle — no extra configuration needed here. |
| 141 | + # ----------------------------------------------------------------------- |
| 142 | + - name: Build AAR |
| 143 | + working-directory: approov-service-retrofit |
| 144 | + run: ./gradlew assembleRelease |
| 145 | + |
| 146 | + - name: Run unit tests |
| 147 | + working-directory: approov-service-retrofit |
| 148 | + env: |
| 149 | + TESTING_REPLY_URL: ${{ env.TESTING_REPLY_URL }} |
| 150 | + TESTING_REPLY_URL_UNPROTECTED: ${{ env.TESTING_REPLY_URL_UNPROTECTED }} |
| 151 | + run: ./gradlew test |
| 152 | + |
| 153 | + # ----------------------------------------------------------------------- |
| 154 | + # 6. Print test summary to the console and to the Actions Job Summary |
| 155 | + # Parses every JUnit XML produced by Gradle and emits: |
| 156 | + # • a formatted table in the step log |
| 157 | + # • a markdown table in the Job Summary (Actions UI → Summary tab) |
| 158 | + # ----------------------------------------------------------------------- |
| 159 | + - name: Print test summary |
| 160 | + if: always() |
| 161 | + working-directory: approov-service-retrofit |
| 162 | + run: | |
| 163 | + python3 - <<'EOF' |
| 164 | + import os, sys, glob, xml.etree.ElementTree as ET |
| 165 | + from collections import defaultdict |
| 166 | +
|
| 167 | + PASS = "\u2705" |
| 168 | + FAIL = "\u274c" |
| 169 | + SKIP = "\u23ed\ufe0f" |
| 170 | + ERROR = "\u26a0\ufe0f" |
| 171 | +
|
| 172 | + results_root = "approov-service/build/test-results" |
| 173 | + xml_files = sorted(glob.glob(f"{results_root}/**/*.xml", recursive=True)) |
| 174 | +
|
| 175 | + if not xml_files: |
| 176 | + print("No test-result XML files found.") |
| 177 | + sys.exit(0) |
| 178 | +
|
| 179 | + # Group suites by variant directory name (testDebugUnitTest, etc.) |
| 180 | + by_variant = defaultdict(list) |
| 181 | + for path in xml_files: |
| 182 | + variant = os.path.basename(os.path.dirname(path)) |
| 183 | + try: |
| 184 | + root = ET.parse(path).getroot() |
| 185 | + except ET.ParseError: |
| 186 | + continue |
| 187 | + suite = { |
| 188 | + "name": root.get("name", os.path.basename(path)), |
| 189 | + "tests": int(root.get("tests", 0)), |
| 190 | + "failures": int(root.get("failures", 0)), |
| 191 | + "errors": int(root.get("errors", 0)), |
| 192 | + "skipped": int(root.get("skipped", 0)), |
| 193 | + "time": float(root.get("time", 0)), |
| 194 | + "cases": [], |
| 195 | + } |
| 196 | + for tc in root.findall("testcase"): |
| 197 | + status = PASS |
| 198 | + detail = "" |
| 199 | + if tc.find("failure") is not None: |
| 200 | + status = FAIL |
| 201 | + detail = (tc.find("failure").get("message") or "").split("\n")[0][:120] |
| 202 | + elif tc.find("error") is not None: |
| 203 | + status = ERROR |
| 204 | + detail = (tc.find("error").get("message") or "").split("\n")[0][:120] |
| 205 | + elif tc.find("skipped") is not None: |
| 206 | + status = SKIP |
| 207 | + suite["cases"].append({ |
| 208 | + "name": tc.get("name", "?"), |
| 209 | + "time": float(tc.get("time", 0)), |
| 210 | + "status": status, |
| 211 | + "detail": detail, |
| 212 | + }) |
| 213 | + by_variant[variant].append(suite) |
| 214 | +
|
| 215 | + # ── Console output ────────────────────────────────────────────────── |
| 216 | + overall_ok = True |
| 217 | + for variant, suites in sorted(by_variant.items()): |
| 218 | + total_t = sum(s["tests"] for s in suites) |
| 219 | + total_f = sum(s["failures"] + s["errors"] for s in suites) |
| 220 | + total_s = sum(s["skipped"] for s in suites) |
| 221 | + total_p = total_t - total_f - total_s |
| 222 | + icon = PASS if total_f == 0 else FAIL |
| 223 | + if total_f > 0: |
| 224 | + overall_ok = False |
| 225 | + print() |
| 226 | + print(f"{'='*70}") |
| 227 | + print(f" {icon} {variant} | {total_p}/{total_t} passed " |
| 228 | + f"| {total_f} failed | {total_s} skipped") |
| 229 | + print(f"{'='*70}") |
| 230 | + for suite in suites: |
| 231 | + short = suite["name"].split(".")[-1] |
| 232 | + p = suite["tests"] - suite["failures"] - suite["errors"] - suite["skipped"] |
| 233 | + f = suite["failures"] + suite["errors"] |
| 234 | + print(f" {PASS if f==0 else FAIL} {short:<55} {p}/{suite['tests']} ({suite['time']:.2f}s)") |
| 235 | + for case in suite["cases"]: |
| 236 | + if case["status"] != PASS: |
| 237 | + print(f" {case['status']} {case['name']}") |
| 238 | + if case["detail"]: |
| 239 | + print(f" {case['detail']}") |
| 240 | + print() |
| 241 | +
|
| 242 | + # ── GitHub Job Summary (markdown) ─────────────────────────────────── |
| 243 | + summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "") |
| 244 | + if not summary_path: |
| 245 | + sys.exit(0 if overall_ok else 1) |
| 246 | +
|
| 247 | + with open(summary_path, "a") as md: |
| 248 | + md.write("## \U0001f9ea Unit Test Results\n\n") |
| 249 | + for variant, suites in sorted(by_variant.items()): |
| 250 | + total_t = sum(s["tests"] for s in suites) |
| 251 | + total_f = sum(s["failures"] + s["errors"] for s in suites) |
| 252 | + total_s = sum(s["skipped"] for s in suites) |
| 253 | + total_p = total_t - total_f - total_s |
| 254 | + badge = "\U0001f7e2 PASSED" if total_f == 0 else "\U0001f534 FAILED" |
| 255 | + md.write(f"### {variant} {badge}\n\n") |
| 256 | + md.write(f"> **{total_p} passed** | " |
| 257 | + f"**{total_f} failed** | " |
| 258 | + f"**{total_s} skipped** | " |
| 259 | + f"**{total_t} total**\n\n") |
| 260 | + md.write("| Status | Test suite | Tests | Failed | Skipped | Time |\n") |
| 261 | + md.write("|--------|------------|------:|-------:|--------:|-----:|\n") |
| 262 | + for suite in suites: |
| 263 | + short = suite["name"].split(".")[-1] |
| 264 | + f = suite["failures"] + suite["errors"] |
| 265 | + icon = "\U0001f7e2" if f == 0 else "\U0001f534" |
| 266 | + md.write(f"| {icon} | `{short}` | {suite['tests']} | {f} | {suite['skipped']} | {suite['time']:.2f}s |\n") |
| 267 | + # Expand failures inline |
| 268 | + failures = [c for s in suites for c in s["cases"] if c["status"] in (FAIL, ERROR)] |
| 269 | + if failures: |
| 270 | + md.write("\n<details><summary>Failed tests</summary>\n\n") |
| 271 | + for c in failures: |
| 272 | + md.write(f"- {c['status']} `{c['name']}`") |
| 273 | + if c["detail"]: |
| 274 | + md.write(f" \n > {c['detail']}") |
| 275 | + md.write("\n") |
| 276 | + md.write("\n</details>\n") |
| 277 | + md.write("\n") |
| 278 | +
|
| 279 | + sys.exit(0 if overall_ok else 1) |
| 280 | + EOF |
| 281 | +
|
| 282 | + # ----------------------------------------------------------------------- |
| 283 | + # 7. Upload HTML reports as a downloadable artifact |
| 284 | + # ----------------------------------------------------------------------- |
| 285 | + - name: Upload test results |
| 286 | + if: always() |
| 287 | + uses: actions/upload-artifact@v7 |
| 288 | + with: |
| 289 | + name: test-results-${{ github.run_number }} |
| 290 | + path: approov-service-retrofit/approov-service/build/reports/tests/ |
| 291 | + retention-days: 14 |
0 commit comments