From 7d4caf42865fd5c1cc196616fd2686c265fa79ef Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 1 Sep 2026 16:56:07 -0500 Subject: [PATCH] feat(plugins): ADFA-5395 record build provenance in the AI plugins Wire ${pluginVcsRevision}/${pluginBuildTimestamp} into the six AI manifests, bump versions where history moved past them, and export PLUGIN_LIBS_REVISION from update-libs.sh. Needs the libs/ refresh first, or the merger fails. --- .claude/skills/plugin-review/SKILL.md | 4 + CLAUDE.md | 35 +++++++- ai-agent-gemini/src/main/AndroidManifest.xml | 10 +++ ai-agent-local/build.gradle.kts | 4 +- ai-agent-local/src/main/AndroidManifest.xml | 10 +++ ai-agent-mcp/src/main/AndroidManifest.xml | 10 +++ ai-agent-openai/src/main/AndroidManifest.xml | 10 +++ ai-core/build.gradle.kts | 4 +- ai-core/src/main/AndroidManifest.xml | 10 +++ plugins/Get-AI-Models/build.gradle.kts | 4 +- .../src/main/AndroidManifest.xml | 10 +++ plugins/NDK-Installer/.gitignore | 5 ++ plugins/NDK-Installer/build.gradle.kts | 18 ++-- .../src/main/assets/ndk-cmake.tar.xz | 1 - scripts/update-libs.sh | 86 +++++++++++++++++++ 15 files changed, 205 insertions(+), 16 deletions(-) delete mode 100644 plugins/NDK-Installer/src/main/assets/ndk-cmake.tar.xz diff --git a/.claude/skills/plugin-review/SKILL.md b/.claude/skills/plugin-review/SKILL.md index 33a6a8ba..837ef4bd 100644 --- a/.claude/skills/plugin-review/SKILL.md +++ b/.claude/skills/plugin-review/SKILL.md @@ -81,6 +81,10 @@ Search the source tree: - `settings.gradle.kts` must NOT include `mavenLocal()` in `dependencyResolutionManagement` (it's also iffy in `pluginManagement`). - All dependency versions pinned — no `+`, `latest.release`, or unbounded ranges. - Phase 1 already verified the build works from a clean checkout. Cite the result here. +- **Provenance record** (ADFA-5256): `unzip -p /build/plugin/.cgp assets/cgp-build.properties` on the artifact Phase 1 built. The file is written unconditionally by any builder from 26.36 on, so its **absence** means the submission was built with a pre-5256 builder — note it, do not fail on it. When present, read `revision_source`: `git`, `git-dir`, `explicit` or `env:*` all satisfy "rebuildable from the linked source". `none` (i.e. `revision=unknown`) means the artifact cannot be traced back to a commit → **Partial**, and tell the author to either build from a checkout or declare `pluginBuilder { pluginVcsRevision = "..." }` for a source-archive distribution. +- **`+dirty` in a submitted artifact** → **Partial**: the tree had uncommitted changes in the plugin's own directory, so the linked source does not reproduce it. +- **Manifest wiring** is optional but graded together with the above: if `src/main/AndroidManifest.xml` declares `plugin.vcs_revision` / `plugin.build_timestamp`, both must use `${pluginVcsRevision}` / `${pluginBuildTimestamp}` verbatim — a hardcoded value defeats the point and is a **Fail**. If neither is declared, the record still exists in the archive but the IDE cannot show it; note it as a recommendation, not a defect. +- Do **not** flag a revision that differs from the linked repo's `HEAD` — an artifact built one commit before a `libs/` refresh is normal in this repo's release pipeline (see CLAUDE.md, *Build provenance*). Cross-check `libs_revision` instead. #### 6.4 Native binaries - `find src -name '*.so'`, look for `jniLibs/`, `externalNativeBuild`, NDK config. diff --git a/CLAUDE.md b/CLAUDE.md index 85b202f2..8e85f62a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,11 +88,40 @@ A plugin is an Android *application* module (despite installing as a library) wi 1. **`build.gradle.kts`** applies `com.android.application`, `org.jetbrains.kotlin.android`, and `com.itsaky.androidide.plugins.build`. Configures `pluginBuilder { pluginName = "..." }`. Uses `compileOnly(files("../../libs/plugin-api.jar"))` — never `implementation`. 2. **`settings.gradle.kts`** declares the jars it needs on the buildscript classpath plus AGP and Kotlin. -3. **`src/main/AndroidManifest.xml`** declares plugin identity as `` entries on ``: `plugin.id`, `plugin.name`, `plugin.version` (resolved from `${pluginVersion}`), `plugin.description`, `plugin.author`, `plugin.main_class`, `plugin.min_ide_version`, and optional `plugin.permissions`. +3. **`src/main/AndroidManifest.xml`** declares plugin identity as `` entries on ``: `plugin.id`, `plugin.name`, `plugin.version` (resolved from `${pluginVersion}`), `plugin.description`, `plugin.author`, `plugin.main_class`, `plugin.min_ide_version`, and optional `plugin.permissions`. Optionally `plugin.vcs_revision` / `plugin.build_timestamp` — see **Build provenance** below. 4. **Main class** implements `com.itsaky.androidide.plugins.IPlugin`. Lifecycle: `initialize(PluginContext) → activate() → deactivate() → dispose()`. Services are obtained via `context.services.get(SomeService::class.java)` (e.g. `IdeBuildService` for build hooks). Android `Context` is `context.androidContext`. Available permission strings (declared comma-separated in `plugin.permissions`): `filesystem.read`, `filesystem.write`, `network.access`, `system.commands`, `ide.settings`, `project.structure`. +### Build provenance (`plugin.vcs_revision`, `cgp-build.properties`) + +Every `.cgp` records the commit it was built from, so a crash report or a support question traces back to source (ADFA-5256). The builder resolves it once per build and publishes it three ways: two `` entries, `assets/cgp-build.properties` inside the archive, and the IDE's plugin details dialog. + +Manifests opt in by referencing the placeholders — the builder never injects `` on your behalf: + +```xml + + +``` + +**This is a hard build-time coupling to `libs/gradle-plugin.jar`.** A manifest that references a placeholder the builder does not define fails the manifest merger outright (*"requires a placeholder substitution but no value ... is provided"*), and all plugins resolve the builder from the single committed jar. So a manifest may only adopt these **after** the builder change is merged in CoGo and the **Update libs from CodeOnTheGo** Action has refreshed `libs/`. Never the other way round. The same coupling hits on-device builders, whose builder jar ships in `plugin-maven-repo.zip` and refreshes only with a CoGo **app release** — a plugin referencing these cannot be built inside an older CoGo at all. + +Read the record out of a built artifact: + +```sh +unzip -p /build/plugin/.cgp assets/cgp-build.properties +``` + +`revision_source` says how the revision was found, in the order the builder tries: `explicit` (you set `pluginBuilder { pluginVcsRevision = "..." }`) → `env:` (`PLUGIN_VCS_REVISION`, `GITHUB_SHA`, `CI_COMMIT_SHA`, `GIT_COMMIT`) → `git` → `git-dir` (reads `.git` directly; this is the on-device path, since CoGo ships JGit in-process and no `git` binary) → `none`, which means `revision=unknown`. `+dirty` is appended when the plugin's **own** directory has uncommitted changes; the check is scoped to that directory so a `libs/` refresh elsewhere in the tree does not flag the build. + +`timestamp` is the committer date of that revision in UTC, not the wall clock, so two builds of one commit produce a byte-identical `.cgp` (see CoGo's ADR-0012). That holds only where the builder could reach a `git` binary; it falls back to the clock and says so with `timestamp_source=wall-clock`, and in that case the stamp lands in the version string too, so the artifact is **not** reproducible. On device it is always the fallback — CoGo ships no `git` — and under `--configuration-cache` the clock reading additionally freezes into the cached configuration. + +`+dirty` has one systemic cause worth designing against: **a build-time download must land on a gitignored path.** A `downloadAssets` task that overwrites a git-tracked file (`ndk-installer` shipped a committed placeholder `ndk-cmake.tar.xz` until it was untracked) dirties the plugin directory on every build, so every artifact it ever produces records `+dirty` and no build of that plugin is traceable to a clean commit. Both download plugins now fetch onto ignored paths (`plugins/NDK-Installer/.gitignore`, `ai-literacy-course/.gitignore`); keep it that way when adding a new one. + +`libs_revision` records which CoGo commit produced the jars the plugin was compiled against. The builder cannot see that checkout, so `scripts/update-libs.sh` exports `PLUGIN_LIBS_REVISION` before the build loop — both Actions workflows inherit it through the script. Note that under **Update libs from CodeOnTheGo** the plugin's own `revision` is the commit *before* the `chore: update libs` commit, because plugins are built before that commit is created; `libs_revision` is what pins the pairing. + +`scripts/update-libs.sh` asserts the record after each `assemblePlugin`: a `.cgp` missing `assets/cgp-build.properties`, missing any required key, or disagreeing with the exported `PLUGIN_LIBS_REVISION` fails the run. `revision=unknown`, `+dirty` and `timestamp_source=wall-clock` warn instead — all three are legitimate off-CI (no `.git`, no `git` binary). Both Actions workflows build through the script, so this covers them without a per-workflow check. + ### In-app help wiring (tooltips + Tier 3, `DocumentationExtension`) Every plugin with UI implements `com.itsaky.androidide.plugins.extensions.DocumentationExtension`. This wiring is fixed and foundational — get **all** of it right or the tooltip renders the literal string **`n/a`** at runtime. The build stays green and the manifest looks fine, so **only device long-press testing catches a mistake** (this bit us once). All symbols are in `plugin-api.jar`. @@ -121,9 +150,9 @@ This is intentional — the `application`-as-library packaging trips those check ### Asset downloads (rare) -Some plugins (`ndk-installer`, `ai-literacy-course`) register a `downloadAssets` task that fetches large files at build time with pinned-MD5 verification. These assets are **not committed to git** (e.g. `ai-literacy-course` pulls a ~110 MB course ZIP plus `pdfjs.zip`). `scripts/update-libs.sh` runs `downloadAssets` automatically before `assemblePlugin` when the build file references it. +Some plugins (`ndk-installer`, `ai-literacy-course`) register a `downloadAssets` task that fetches large files at build time with pinned-MD5 verification. These assets are **not committed to git** — each plugin gitignores its own download paths (`ai-literacy-course` pulls a ~110 MB course ZIP plus `pdfjs.zip`; `ndk-installer` pulls `ndk-cmake.tar.xz`). Committing one, even as a placeholder, makes every build dirty — see **Build provenance** above. `scripts/update-libs.sh` runs `downloadAssets` automatically before `assemblePlugin` when the build file references it. -**Gotcha: a bare `./gradlew assemblePlugin` does NOT run `downloadAssets` and does not warn when the assets are missing** — it silently packages a broken `.cgp` (e.g. a course with no PDF viewer). When building such a plugin by hand, run `./gradlew downloadAssets assemblePlugin` (or the script), and confirm the expected files exist under `src/main/assets/` (or `unzip -l` the `.cgp`) before trusting it. +**Gotcha: a bare `./gradlew assemblePlugin` does NOT run `downloadAssets` and does not warn when the assets are missing** — it silently packages a broken `.cgp` (e.g. a course with no PDF viewer). When building such a plugin by hand, run `./gradlew downloadAssets` and then `./gradlew assemblePlugin` as two separate invocations (or use the script, which does exactly that). Combining them in one invocation fails: `downloadAssets` declares an output inside `src/main/assets`, which Gradle sees as an undeclared dependency of `mergeReleaseAssets`. Then confirm the expected files exist under `src/main/assets/` (or `unzip -l` the `.cgp`) before trusting it. `ndk-installer` is the exception: its asset merge fails outright when `ndk-cmake.tar.xz` is absent, rather than shipping an NDK-less plugin. ### One-time on-device install markers diff --git a/ai-agent-gemini/src/main/AndroidManifest.xml b/ai-agent-gemini/src/main/AndroidManifest.xml index e463434a..96cff1b9 100644 --- a/ai-agent-gemini/src/main/AndroidManifest.xml +++ b/ai-agent-gemini/src/main/AndroidManifest.xml @@ -21,6 +21,16 @@ android:name="plugin.version" android:value="${pluginVersion}" /> + + + + + diff --git a/ai-agent-local/build.gradle.kts b/ai-agent-local/build.gradle.kts index 0b99ef6b..87bff5d4 100644 --- a/ai-agent-local/build.gradle.kts +++ b/ai-agent-local/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "com.itsaky.androidide.plugins.aiagentlocal" minSdk = 33 targetSdk = 36 - versionCode = 2 - versionName = "1.0.1" + versionCode = 3 + versionName = "1.1.0" ndk { abiFilters += listOf("arm64-v8a") diff --git a/ai-agent-local/src/main/AndroidManifest.xml b/ai-agent-local/src/main/AndroidManifest.xml index 3988f4c1..d9725a83 100644 --- a/ai-agent-local/src/main/AndroidManifest.xml +++ b/ai-agent-local/src/main/AndroidManifest.xml @@ -18,6 +18,16 @@ android:name="plugin.version" android:value="${pluginVersion}" /> + + + + + diff --git a/ai-agent-mcp/src/main/AndroidManifest.xml b/ai-agent-mcp/src/main/AndroidManifest.xml index 5c9eeee8..2aaa6391 100644 --- a/ai-agent-mcp/src/main/AndroidManifest.xml +++ b/ai-agent-mcp/src/main/AndroidManifest.xml @@ -21,6 +21,16 @@ android:name="plugin.version" android:value="${pluginVersion}" /> + + + + + diff --git a/ai-agent-openai/src/main/AndroidManifest.xml b/ai-agent-openai/src/main/AndroidManifest.xml index 8684f20e..6266b90a 100644 --- a/ai-agent-openai/src/main/AndroidManifest.xml +++ b/ai-agent-openai/src/main/AndroidManifest.xml @@ -25,6 +25,16 @@ android:name="plugin.version" android:value="${pluginVersion}" /> + + + + + diff --git a/ai-core/build.gradle.kts b/ai-core/build.gradle.kts index 6bf34e08..040894da 100644 --- a/ai-core/build.gradle.kts +++ b/ai-core/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.itsaky.androidide.plugins.aicore" minSdk = 33 targetSdk = 36 - versionCode = 4 - versionName = "3.0.0" + versionCode = 5 + versionName = "3.1.0" } buildFeatures { diff --git a/ai-core/src/main/AndroidManifest.xml b/ai-core/src/main/AndroidManifest.xml index b9756ed9..fa625d74 100644 --- a/ai-core/src/main/AndroidManifest.xml +++ b/ai-core/src/main/AndroidManifest.xml @@ -18,6 +18,16 @@ android:name="plugin.version" android:value="${pluginVersion}" /> + + + + + diff --git a/plugins/Get-AI-Models/build.gradle.kts b/plugins/Get-AI-Models/build.gradle.kts index a0f1f1d7..598322da 100644 --- a/plugins/Get-AI-Models/build.gradle.kts +++ b/plugins/Get-AI-Models/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "org.appdevforall.getaimodels" minSdk = 21 targetSdk = 36 - versionCode = 1 - versionName = "1.0.0" + versionCode = 2 + versionName = "1.1.0" } buildTypes { diff --git a/plugins/Get-AI-Models/src/main/AndroidManifest.xml b/plugins/Get-AI-Models/src/main/AndroidManifest.xml index 7d27bd2a..157acdd1 100644 --- a/plugins/Get-AI-Models/src/main/AndroidManifest.xml +++ b/plugins/Get-AI-Models/src/main/AndroidManifest.xml @@ -17,6 +17,16 @@ android:name="plugin.version" android:value="${pluginVersion}" /> + + + + + diff --git a/plugins/NDK-Installer/.gitignore b/plugins/NDK-Installer/.gitignore index 6af499b7..df88c134 100644 --- a/plugins/NDK-Installer/.gitignore +++ b/plugins/NDK-Installer/.gitignore @@ -27,3 +27,8 @@ Thumbs.db # Test outputs test-results/ + +# Fetched at build time by the downloadAssets task — never commit it. A tracked +# copy here would be overwritten by every download, leaving the plugin directory +# permanently dirty and stamping `+dirty` into the provenance of every .cgp. +src/main/assets/ndk-cmake.tar.xz diff --git a/plugins/NDK-Installer/build.gradle.kts b/plugins/NDK-Installer/build.gradle.kts index 5eeebf16..33431942 100644 --- a/plugins/NDK-Installer/build.gradle.kts +++ b/plugins/NDK-Installer/build.gradle.kts @@ -164,9 +164,15 @@ val downloadAssets by tasks.registering { } } - - - - - - +// The archive is never committed, so fail loudly instead of packaging an NDK-less .cgp. +val ndkArchiveFile = project.file("src/main/assets/ndk-cmake.tar.xz") +tasks.matching { it.name.startsWith("merge") && it.name.endsWith("Assets") }.configureEach { + doFirst { + if (!ndkArchiveFile.isFile) { + throw GradleException( + "Missing src/main/assets/ndk-cmake.tar.xz. It is fetched at build time and " + + "never committed, so run './gradlew downloadAssets' before assembling." + ) + } + } +} diff --git a/plugins/NDK-Installer/src/main/assets/ndk-cmake.tar.xz b/plugins/NDK-Installer/src/main/assets/ndk-cmake.tar.xz deleted file mode 100644 index de3d0277..00000000 --- a/plugins/NDK-Installer/src/main/assets/ndk-cmake.tar.xz +++ /dev/null @@ -1 +0,0 @@ - diff --git a/scripts/update-libs.sh b/scripts/update-libs.sh index 4a0d095a..1b3e34ef 100755 --- a/scripts/update-libs.sh +++ b/scripts/update-libs.sh @@ -133,6 +133,19 @@ echo "Updated libs/ from CodeOnTheGo@$CODEONTHEGO_SHA" printf " %-20s %s\n" "plugin-api.jar" "$(du -h "$LIBS_DIR/plugin-api.jar" | cut -f1)" printf " %-20s %s\n" "gradle-plugin.jar" "$(du -h "$LIBS_DIR/gradle-plugin.jar" | cut -f1)" +# The plugin builder records this in each .cgp's assets/cgp-build.properties as +# libs_revision. It cannot resolve the value itself -- the CodeOnTheGo checkout is +# outside the plugin build -- so without this a released plugin's own revision does +# not identify the plugin-api/gradle-plugin jars it was compiled against. Left unset +# when the sha is unknown, so the field is omitted rather than recorded as a guess. +# Re-resolved at 12 characters rather than reusing $CODEONTHEGO_SHA, which is abbreviated +# to git's default length: the builder records `revision` at 12, and two differently +# shaped shas in one properties file are needlessly hard to compare at a glance. +LIBS_REVISION="$(git -C "$CODEONTHEGO_PATH" rev-parse --short=12 HEAD 2>/dev/null || echo unknown)" +if [ "$LIBS_REVISION" != "unknown" ]; then + export PLUGIN_LIBS_REVISION="$LIBS_REVISION" +fi + # One discovery rule for the whole repository. The tool applies the skip # list in tools/addons/skip.txt. Do not use mapfile here: macOS ships # bash 3.2, which does not have it. @@ -168,6 +181,78 @@ if [ -n "$ONLY_PLUGIN" ]; then PLUGINS=("$match") fi +if ! command -v unzip >/dev/null 2>&1; then + echo "Error: unzip is required to verify the provenance record inside each built .cgp." >&2 + exit 1 +fi + +# The plugin builder writes assets/cgp-build.properties into every .cgp (see the +# Build provenance section of CLAUDE.md). Nothing in the Gradle build fails when +# that record is missing, truncated or unreadable — the archive still assembles +# either way — so a builder or asset-packaging regression would ship provenance-less +# artifacts and only be noticed once a crash report could no longer be traced to +# a commit. Assert the record here: both release workflows drive their builds +# through this script, so this is the one chokepoint that covers all of them. +REQUIRED_PROVENANCE_KEYS=(name version variant revision revision_source timestamp timestamp_source) + +verify_provenance() { + local plugin="$1" + local cgp props key value revision revision_source timestamp_source recorded_libs + cgp="$(ls "$REPO_ROOT/$plugin"/build/plugin/*.cgp 2>/dev/null | grep -v -- '-debug\.cgp$' | head -n1 || true)" + if [ -z "$cgp" ]; then + echo "Error: $plugin assembled no release .cgp under build/plugin/." >&2 + return 1 + fi + + if ! props="$(unzip -p "$cgp" assets/cgp-build.properties 2>/dev/null)" || [ -z "$props" ]; then + echo "Error: $(basename "$cgp") does not contain assets/cgp-build.properties." >&2 + echo " The builder in libs/gradle-plugin.jar either no longer generates the" >&2 + echo " provenance record or no longer packages it as an asset." >&2 + return 1 + fi + + for key in "${REQUIRED_PROVENANCE_KEYS[@]}"; do + value="$(printf '%s\n' "$props" | sed -n "s/^${key}=//p" | head -n1)" + if [ -z "$value" ]; then + echo "Error: $(basename "$cgp") provenance record has no '$key' value." >&2 + printf '%s\n' "$props" | sed 's/^/ /' >&2 + return 1 + fi + done + + revision="$(printf '%s\n' "$props" | sed -n 's/^revision=//p' | head -n1)" + revision_source="$(printf '%s\n' "$props" | sed -n 's/^revision_source=//p' | head -n1)" + timestamp_source="$(printf '%s\n' "$props" | sed -n 's/^timestamp_source=//p' | head -n1)" + recorded_libs="$(printf '%s\n' "$props" | sed -n 's/^libs_revision=//p' | head -n1)" + + # libs_revision only exists because this script exports PLUGIN_LIBS_REVISION + # above, so a mismatch means the export stopped reaching the build and every + # artifact this run publishes has lost the pairing to the jars it compiled + # against. + if [ -n "${PLUGIN_LIBS_REVISION:-}" ] && [ "$recorded_libs" != "$PLUGIN_LIBS_REVISION" ]; then + echo "Error: $(basename "$cgp") recorded libs_revision='$recorded_libs', expected '$PLUGIN_LIBS_REVISION'." >&2 + return 1 + fi + + # Warnings, not errors: both are legitimate outside CI (a checkout with no + # .git, a machine with no git binary), but in a workflow run they mean the + # artifact cannot be traced back or reproduced. + if [ "$revision" = "unknown" ] || [ "$revision_source" = "none" ]; then + echo "Warning: $plugin recorded revision=unknown — the .cgp cannot be traced to a commit." >&2 + fi + case "$revision" in + *+dirty) + echo "Warning: $plugin was built from a dirty $plugin/ directory, recorded as '$revision'." >&2 + echo " Build-time downloads must land on gitignored paths, never on tracked files." >&2 + ;; + esac + if [ "$timestamp_source" = "wall-clock" ]; then + echo "Warning: $plugin stamped a wall-clock timestamp — this .cgp is not reproducible." >&2 + fi + + echo " provenance: revision=$revision ($revision_source) libs_revision=${recorded_libs:-} timestamp_source=$timestamp_source" +} + echo "" echo "Discovered example plugins: ${PLUGINS[*]}" echo "Building all example plugins against the refreshed libs..." @@ -185,6 +270,7 @@ for plugin in "${PLUGINS[@]}"; do fi "$gradlew" --console=plain assemblePlugin ) + verify_provenance "$plugin" done echo "" echo "All plugins built successfully." \ No newline at end of file