diff --git a/.github/workflows/android-sanity-check.yml b/.github/workflows/android-sanity-check.yml index 088db55..f4a0ae3 100644 --- a/.github/workflows/android-sanity-check.yml +++ b/.github/workflows/android-sanity-check.yml @@ -20,6 +20,12 @@ jobs: name: Lint & Compile Check runs-on: ubuntu-latest + # M15: expose a token to the gradle steps so the native-artifact sync can fall + # back to an authenticated GitHub API call when the unauthenticated one is + # rate-limited (HTTP 403). secrets.GITHUB_TOKEN is provided automatically. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + defaults: run: working-directory: ./controller diff --git a/controller/app/build.gradle b/controller/app/build.gradle index 35a5e08..e6128b1 100644 --- a/controller/app/build.gradle +++ b/controller/app/build.gradle @@ -224,19 +224,10 @@ task syncNativeArtifacts { def targetTag = versionFile.text.trim() println " -> Target tag pinned to: ${targetTag}" - // 2. Fetch the exact Release directly from GitHub API - def apiUrl = new URL("https://api.github.com/repos/${repoName}/releases/tags/${targetTag}") - def connection = (HttpURLConnection) apiUrl.openConnection() - connection.setRequestProperty("Accept", "application/vnd.github.v3+json") - - if (connection.responseCode != 200) { - throw new GradleException(">> [IIAB Hook] GitHub API error: Tag ${targetTag} not found (HTTP ${connection.responseCode})") - } - + // 2. Smart caching FIRST -- never touch the network if the pinned + // binaries are already present and verified locally (M15: do not + // couple every build to the GitHub API). def slurper = new JsonSlurper() - def targetRelease = slurper.parse(connection.inputStream) - - // 3. Smart Caching: Skip if we already have this exact release and all files exist! def currentTag = tagTrackerFile.exists() ? tagTrackerFile.text.trim() : "" // List all critical binaries that must exist @@ -257,13 +248,43 @@ task syncNativeArtifacts { // We also verify that the manifest exists in order to be able to audit. def manifestFile = new File(assetsDir, "ninja_manifest.json") - def isCached = (currentTag == targetRelease.tag_name && allBinariesExist && manifestFile.exists()) + def isCached = (currentTag == targetTag && allBinariesExist && manifestFile.exists()) if (isCached) { - println " -> [CACHED] Artifacts are up to date (${currentTag}) and physically present. Skipping download." + println " -> [CACHED] Artifacts are up to date (${currentTag}) and physically present. Skipping API + download." } else { println " -> Cache miss, missing physical files, or missing manifest. Proceeding with download..." + // 3. Fetch release metadata. Try UNAUTHENTICATED first so a fork + // without a token still builds; only if that fails (e.g. HTTP 403 + // rate-limit) retry WITH a token when one is available in the + // environment. Independent builds never need to define a token. + def openReleaseConn = { String authToken -> + def relUrl = new URL("https://api.github.com/repos/${repoName}/releases/tags/${targetTag}") + def c = (HttpURLConnection) relUrl.openConnection() + c.setRequestProperty("Accept", "application/vnd.github.v3+json") + c.setRequestProperty("User-Agent", "iiab-android-build") + if (authToken?.trim()) c.setRequestProperty("Authorization", "Bearer ${authToken.trim()}") + return c + } + + def connection = openReleaseConn(null) // attempt 1: unauthenticated + int code = connection.responseCode + if (code != 200) { + def envToken = System.getenv("GITHUB_TOKEN") ?: System.getenv("GH_TOKEN") + if (envToken?.trim()) { + println " -> Unauthenticated API call returned HTTP ${code}; retrying with token (first attempt likely rate-limited)..." + connection = openReleaseConn(envToken) // attempt 2: authenticated fallback + code = connection.responseCode + } + } + if (code != 200) { + throw new GradleException(">> [IIAB Hook] GitHub API error for tag ${targetTag}: HTTP ${code}. " + + "This is usually GitHub API rate-limiting; set GITHUB_TOKEN (CI provides one automatically) to raise the limit.") + } + + def targetRelease = slurper.parse(connection.inputStream) + // 4. Find the zip asset URL in the targeted release def zipAsset = targetRelease.assets.find { it.name == 'termux-binaries-latest.zip' } if (zipAsset == null) { diff --git a/controller/docs/TECH_DEBT_PLAN.md b/controller/docs/TECH_DEBT_PLAN.md index a637c4a..fa101a3 100644 --- a/controller/docs/TECH_DEBT_PLAN.md +++ b/controller/docs/TECH_DEBT_PLAN.md @@ -57,6 +57,10 @@ _Last updated: 2026-06-17. Tracks remediation work against the findings below. I - New shared `util/ProcessRunner.run(cmd)`: `redirectErrorStream(true)` + full drain + returns `{exitCode, output}`, so a single read cannot deadlock and callers can log/handle failures. - Migrated the raw `exec().waitFor()` sites (backup, `chmod -R`, the three `rm -rf` wipes); empty catches now log. Left the extraction path (already drains stderr) and the `getprop` read (reads stdout) as-is. No new unit test (process glue, not pure logic — fragile to run a shell in unit tests on a Windows dev box); verified by inspection + CI compile. +**M15 — Build coupled to network for native artifacts: FIXED** (PR `fix/m15-build-binary-sync-fallback`) +- `:app:syncNativeArtifacts` (`preBuild` dependency) called the GitHub API **unauthenticated on every build**; since `jniLibs/*.so` are gitignored, CI always downloads and hit GitHub's 60/hr unauthenticated limit -> intermittent **HTTP 403** ("tag not found"), failing `assembleDebug` on unrelated PRs. +- Fix: (1) **cache-first** — check the local tracker/binaries/manifest before any network call, so builds with artifacts present skip the API entirely; (2) **fallback auth** — fetch release metadata UNAUTHENTICATED first (so forks without a token still build), and only on failure retry with `GITHUB_TOKEN`/`GH_TOKEN` from the env; (3) clearer rate-limit error. CI passes `secrets.GITHUB_TOKEN` to the gradle steps (job-level env). No token is ever *required*. + **Phase 1 — Security hardening: IN PROGRESS.** Done so far: **S1** (PR #9), **M4**, **S3** (PR #10), **D6** (PR #12), **D2** (PR #13), **D12**. Remaining: **D11**, **S4**, **F15**. ## 1. Executive summary