Skip to content

Bump agp from 8.5.0 to 8.13.2 #10

Bump agp from 8.5.0 to 8.13.2

Bump agp from 8.5.0 to 8.13.2 #10

name: Dependency Impact Analysis
# Triggered by Dependabot (or manual dispatch). Runs two independent pipelines
# in parallel so a full report lands in ~10-12 min:
#
# static-pipeline (≈ 3-5 min): kmp-impact-analyzer static graph,
# per-target labels, GitGraph SBOM+arc diagram,
# Sunburst, CodeCharta viewer.
#
# droidbot (≈ 8-11 min): BEFORE/AFTER APK build, emulator boot,
# DroidBot exploration → UTGs.
#
# The merge job then fuses both outputs into one HTML report and one PR
# comment with the rasterized sunburst PNG + GitGraph markdown.
on:
pull_request:
paths:
- "**/libs.versions.toml"
- ".github/workflows/impact-analysis.yml"
- "tools/kmp-impact-analyzer/**"
- "pipeline/**"
workflow_dispatch:
inputs:
dependency:
description: "Dependency group (e.g. io.ktor)"
required: true
before_version:
description: "Version before"
required: true
after_version:
description: "Version after"
required: true
permissions:
contents: write
pull-requests: write
pages: write
id-token: write
jobs:
# ---------------------------------------------------------------------------
detect:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.detect.outputs.has_changes }}
dependency_group: ${{ steps.detect.outputs.dependency_group }}
before_version: ${{ steps.detect.outputs.before_version }}
after_version: ${{ steps.detect.outputs.after_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install ./tools/kmp-impact-analyzer
- name: Export catalogs
id: catalog
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# Use the merge-base (the commit where the PR branch diverged
# from main) instead of the current main HEAD, otherwise any
# commits pushed to main *after* Dependabot opened the PR would
# leak into the diff and confuse the version-change detector
# (e.g. it would report a random image-loader bump on a kotlin
# PR). Three-dot diff syntax is the git equivalent and is also
# what GitHub uses for the "Files changed" tab.
MERGE_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA")
echo "merge-base=$MERGE_BASE"
mapfile -t cats < <(git diff --name-only "$MERGE_BASE" "$HEAD_SHA" -- '**/libs.versions.toml')
if [ "${#cats[@]}" -eq 0 ]; then
echo "catalog_path=" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "catalog_path=${cats[0]}" >> "$GITHUB_OUTPUT"
mkdir -p .tmp
git show "$MERGE_BASE:${cats[0]}" > .tmp/base.toml
git show "$HEAD_SHA:${cats[0]}" > .tmp/head.toml
- name: Detect changes
id: detect
if: steps.catalog.outputs.catalog_path != ''
run: kmp-impact detect-version-changes --before .tmp/base.toml --after .tmp/head.toml --format github >> "$GITHUB_OUTPUT"
# ---------------------------------------------------------------------------
# Static pipeline: kmp-impact analysis, GitGraph, sunburst, labels, CodeCharta
# viewer download. Never touches an emulator, so it ~5 min max.
# ---------------------------------------------------------------------------
static-pipeline:
needs: detect
if: |
always() && (
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && needs.detect.outputs.has_changes == 'true')
)
runs-on: ubuntu-latest
env:
DEP_GROUP: ${{ github.event_name == 'pull_request' && needs.detect.outputs.dependency_group || github.event.inputs.dependency }}
BEFORE_VER: ${{ github.event_name == 'pull_request' && needs.detect.outputs.before_version || github.event.inputs.before_version }}
AFTER_VER: ${{ github.event_name == 'pull_request' && needs.detect.outputs.after_version || github.event.inputs.after_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-java@v4
with:
distribution: temurin
# Use JDK 21 so the workflow can compile both AGP 8.x (legacy) and
# AGP 9.x projects (newer KMP templates) without version juggling.
java-version: 21
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: tools/kmp-impact-analyzer/pyproject.toml
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install system deps for cairosvg
run: |
sudo apt-get update -y
sudo apt-get install -y libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0
- name: Install analyzer + cairosvg
run: |
pip install ./tools/kmp-impact-analyzer
pip install "cairosvg==2.7.1"
# ── Per-target compile → PR labels (common / android / ios) ──────────
- name: Compile Kotlin targets (AFTER)
id: compile
continue-on-error: true
run: |
chmod +x gradlew
# Autodetect the KMP module holding commonMain. Different templates
# use different conventions (shared / composeApp / androidApp / app
# / common); when none of those exist we scan for the first folder
# holding a src/commonMain directory so we don't silently report
# red labels because the module name didn't match.
SHARED=""
for m in shared composeApp androidApp app common kmm-shared kmpShared; do
if [ -d "$m/src/commonMain" ]; then SHARED=":$m"; break; fi
done
if [ -z "$SHARED" ]; then
COMMON_MAIN=$(find . -maxdepth 5 -path './.git' -prune -o \
-type d -name 'commonMain' -print | head -1)
if [ -n "$COMMON_MAIN" ]; then
MOD_PATH=$(echo "$COMMON_MAIN" | sed -E 's|^\./||; s|/src/commonMain$||')
SHARED=":$(echo "$MOD_PATH" | tr '/' ':')"
fi
fi
[ -z "$SHARED" ] && SHARED=":app"
echo "Shared module: $SHARED"
common_ok=true; android_ok=true; ios_ok=true
./gradlew "${SHARED}:compileCommonMainKotlinMetadata" --no-daemon 2>&1 | tail -60 \
|| common_ok=false
./gradlew "${SHARED}:compileDebugKotlinAndroid" --no-daemon 2>&1 | tail -60 \
|| android_ok=false
./gradlew "${SHARED}:compileKotlinIosX64" --no-daemon 2>&1 | tail -60 \
|| ios_ok=false
echo "common_ok=$common_ok" >> "$GITHUB_OUTPUT"
echo "android_ok=$android_ok" >> "$GITHUB_OUTPUT"
echo "ios_ok=$ios_ok" >> "$GITHUB_OUTPUT"
- name: Apply PR labels (common / android / ios)
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
COMMON_OK: ${{ steps.compile.outputs.common_ok }}
ANDROID_OK: ${{ steps.compile.outputs.android_ok }}
IOS_OK: ${{ steps.compile.outputs.ios_ok }}
with:
# Three labels — common / android / ios — whose colour reflects
# whether the corresponding KMP target compiled in this run. The
# label colour is repainted each run because the colour itself
# carries the ok/fail signal (green = ok, red = fail).
script: |
const GREEN = '0E8A16';
const RED = 'D93F0B';
const ok = s => s === 'true';
const targets = {
common: ok(process.env.COMMON_OK),
android: ok(process.env.ANDROID_OK),
ios: ok(process.env.IOS_OK),
};
const owner = context.repo.owner;
const repo = context.repo.repo;
async function ensureLabel(name, color, description) {
try {
await github.rest.issues.updateLabel({
owner, repo, name, color, description,
});
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({
owner, repo, name, color, description,
});
} else { throw e; }
}
}
for (const [name, isOk] of Object.entries(targets)) {
await ensureLabel(
name,
isOk ? GREEN : RED,
`KMP ${name} target compiled OK on this run (auto-managed)`,
);
}
// Drop legacy v9-era labels from this PR if they were left behind.
const legacy = [
'common-ok','common-fail','android-ok','android-fail',
'ios-ok','ios-fail',
'common:✅','common:❌',
'android:✅','android:❌',
'ios:✅','ios:❌',
];
const pr = context.issue.number;
const { data: current } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: pr,
});
for (const l of current) {
if (legacy.includes(l.name)) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr, name: l.name,
});
}
}
// Always apply all three so the trio is visible. Colour conveys
// the per-target ok/fail.
const want = ['common','android','ios'];
const toAdd = want.filter(n => !current.some(l => l.name === n));
if (toAdd.length) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr, labels: toAdd,
});
}
# ── kmp-impact static-only (dynamic slot filled by droidbot job) ──────
- name: Run kmp-impact analyze (static)
run: |
kmp-impact analyze \
--repo . \
--dependency "$DEP_GROUP" \
--before-version "$BEFORE_VER" \
--after-version "$AFTER_VER" \
--output-dir /tmp/output \
--skip-dynamic
# ── GitGraph SBOM pipeline ───────────────────────────────────────────
- name: GitGraph — compute merge-base
id: gbase
if: github.event_name == 'pull_request'
run: |
MB=$(git merge-base "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")
echo "merge_base=$MB" >> "$GITHUB_OUTPUT"
echo "Using merge-base=$MB (instead of main HEAD) so unrelated commits don't leak into the dep-graph diff."
- name: GitGraph — fetch diff
id: ggdiff
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.DEP_GRAPH_PAT || secrets.GITHUB_TOKEN }}
GITHUB_OWNER: ${{ github.repository_owner }}
GITHUB_REPO: ${{ github.event.repository.name }}
BASE_SHA: ${{ steps.gbase.outputs.merge_base || github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
DIFF_OUT_PATH: pipeline/ci/diff.json
run: node pipeline/ci/get_diff.js
- name: GitGraph — fetch SBOM
id: ggsbom
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.DEP_GRAPH_PAT || secrets.GITHUB_TOKEN }}
GITHUB_OWNER: ${{ github.repository_owner }}
GITHUB_REPO: ${{ github.event.repository.name }}
SBOM_OUTPUT_PATH: pipeline/sbom/sbom.json
run: node pipeline/sbom/fetch_sbom.js
- name: GitGraph — transform SBOM
if: steps.ggsbom.outcome == 'success'
continue-on-error: true
env:
SBOM_INPUT_PATH: pipeline/sbom/sbom.json
GRAPH_OUTPUT_PATH: pipeline/sbom/graph.json
run: node pipeline/sbom/transform_sbom.js
- name: GitGraph — analyze impact
if: steps.ggdiff.outcome == 'success' && hashFiles('pipeline/sbom/graph.json') != ''
continue-on-error: true
env:
GRAPH_PATH: pipeline/sbom/graph.json
DIFF_PATH: pipeline/ci/diff.json
REPORT_PATH: pipeline/ci/report.json
PAGES_BASE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}
run: node pipeline/ci/analyze_impact.js
# ── CodeCharta viewer (cached) ───────────────────────────────────────
- name: Cache CodeCharta viewer
id: cc-cache
uses: actions/cache@v4
with:
path: /tmp/codecharta-viewer
key: codecharta-vis-1.142.0
- name: Download CodeCharta viewer
if: steps.cc-cache.outputs.cache-hit != 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download vis-1.142.0 --repo MaibornWolff/codecharta \
--pattern 'web.zip' --dir /tmp/ --clobber
unzip -q /tmp/web.zip -d /tmp/cc-extract/
mv /tmp/cc-extract/dist/bundler/browser /tmp/codecharta-viewer
# ── Stage GitGraph + CodeCharta assets into /tmp/output ──────────────
- name: Stage viewer assets
run: |
mkdir -p /tmp/output/gitgraph /tmp/output/sbom
[ -f pipeline/visualization/arc_diagram.html ] && cp pipeline/visualization/arc_diagram.html /tmp/output/gitgraph/
[ -f pipeline/sbom/graph.json ] && cp pipeline/sbom/graph.json /tmp/output/sbom/graph.json
[ -f pipeline/ci/report.json ] && cp pipeline/ci/report.json /tmp/output/gitgraph/
[ -f pipeline/ci/diff.json ] && cp pipeline/ci/diff.json /tmp/output/gitgraph/
if [ -d /tmp/codecharta-viewer ]; then
mkdir -p /tmp/output/phase5
cp -R /tmp/codecharta-viewer /tmp/output/phase5/codecharta-viewer
fi
# ── Surface why GitGraph might be missing in the final report ──
{
echo "## GitGraph staging status"
echo ""
if [ -f pipeline/sbom/graph.json ]; then
nodes=$(node -e "console.log(require('./pipeline/sbom/graph.json').nodes.length)" 2>/dev/null || echo '?')
echo "- graph.json staged ($nodes nodes)"
else
echo "- graph.json **missing** &mdash; SBOM fetch/transform did not produce it."
echo " Likely cause: HTTP 403/404 on Dependency Graph API."
echo " Verify Settings &rarr; Security &rarr; Dependency graph is ON, and Actions workflow permissions are Read+Write."
fi
if [ -f pipeline/visualization/arc_diagram.html ]; then
echo "- arc_diagram.html staged"
else
echo "- arc_diagram.html **missing**"
fi
} >> "$GITHUB_STEP_SUMMARY"
find /tmp/output -maxdepth 3 -type d | head -30
- uses: actions/upload-artifact@v4
with:
name: static-output
path: /tmp/output/
retention-days: 7
# ---------------------------------------------------------------------------
# DroidBot pipeline: build BEFORE + AFTER APKs, boot an Android emulator and
# run DroidBot on each. Runs in parallel with static-pipeline.
# ---------------------------------------------------------------------------
droidbot:
needs: detect
if: |
always() && (
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && needs.detect.outputs.has_changes == 'true')
)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-java@v4
with:
distribution: temurin
# AGP 9.x requires JDK 21+. JDK 21 is fully backwards compatible
# with everything we previously ran on JDK 17 (AGP 7.x/8.x, KMP).
java-version: 21
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install DroidBot (from GitHub, patched for AGP 8+ resources)
run: |
set -euo pipefail
pip install "androguard==3.4.0a1" "droidbot @ git+https://github.com/honeynet/droidbot.git"
sed -i 's/from start import/from droidbot.start import/' "$(which droidbot)"
PKG=$(python3 -c "import importlib.util; print(importlib.util.find_spec('droidbot').submodule_search_locations[0])")
python3 <<PATCH
from pathlib import Path
app_py = Path("$PKG/app.py")
src = app_py.read_text()
lines, patched, changed = src.split('\n'), [], False
for line in lines:
guarded = False
for m in ['get_app_name', 'get_main_activity', 'get_permissions', 'get_activities']:
if f'self.apk.{m}()' in line and 'try' not in line:
indent = line[:len(line) - len(line.lstrip())]
attr = line.split('=')[0].strip()
default = '[]' if ('activities' in attr or 'permissions' in attr) \
else 'self.package_name or "unknown"' if 'name' in attr else 'None'
patched.append(f'{indent}try:')
patched.append(f'{indent} {line.strip()}')
patched.append(f'{indent}except Exception:')
patched.append(f'{indent} {attr} = {default}')
guarded = changed = True
break
if not guarded:
patched.append(line)
if changed:
app_py.write_text('\n'.join(patched))
print("Patched DroidBot app.py")
PATCH
droidbot --help | head -3 || true
# ── Detect Android app module ────────────────────────────────────────
# The conventional name varies wildly across KMP templates:
# :app — single-module Android project
# :composeApp — Compose Multiplatform template
# :androidApp / :android-app — KMP+native templates
# :android — MohamedRejeb/Pokedex layout (compose-multi)
# :androidMain — kotlin/kmp-production-sample
# :app:android — touchlab/DroidconKotlin
# We probe each, and if none matches we fall back to a heuristic that
# scans every folder containing an AndroidManifest.xml under src/main
# so a workflow misconfigured by a fork still produces a real APK and
# DroidBot never gets silently skipped because of a naming mismatch.
- name: Detect Android app module
id: detect-app
run: |
chmod +x gradlew
# KMP / Compose Multiplatform splits manifests across either
# `src/main/AndroidManifest.xml` (vanilla Android module) or
# `src/androidMain/AndroidManifest.xml` (KMP convention). We probe
# both layouts for every well-known module name first, then fall
# back to a recursive scan so unusual module naming still works.
probe() {
local mod="$1" path="${1//://}"
for sub in main androidMain; do
if [ -f "$path/src/$sub/AndroidManifest.xml" ]; then
echo "app_module=:${mod}" >> "$GITHUB_OUTPUT"
echo "Android module (well-known name): :${mod} (manifest in src/$sub)"
return 0
fi
done
return 1
}
for mod in app composeApp androidApp android-app android androidMain shared:android app:android; do
probe "$mod" && exit 0
done
# Fallback heuristic: first module under repo root that holds any
# AndroidManifest.xml (under either src/main or src/androidMain).
MANIFEST=$(find . -maxdepth 6 -path './.git' -prune -o \
\( -path '*/src/main/AndroidManifest.xml' -o \
-path '*/src/androidMain/AndroidManifest.xml' \) \
-print 2>/dev/null | head -1)
if [ -n "$MANIFEST" ]; then
MOD_PATH=$(echo "$MANIFEST" | sed -E 's|^\./||; s|/src/(main|androidMain)/AndroidManifest.xml$||')
GRADLE_PATH=":$(echo "$MOD_PATH" | tr '/' ':')"
echo "app_module=$GRADLE_PATH" >> "$GITHUB_OUTPUT"
echo "Android module (heuristic): $GRADLE_PATH (manifest at $MANIFEST)"
exit 0
fi
echo "::error::No Android app module found — DroidBot will be unable to run."
echo "app_module=" >> "$GITHUB_OUTPUT"
# ── Build APKs. Surface the full error stream rather than tail -20. ──
# ── Upgrade Gradle wrapper if the bundled version is too old ────────
# Forks of older KMP projects ship gradle-wrapper.properties pinned to
# 8.4 / 8.5-rc / etc. AGP 8.5+ requires Gradle 8.7+, AGP 9.x requires
# Gradle 9+. Rather than asking every fork to bump their wrapper, we
# pin the distributionUrl to a sufficiently new version right before
# building. This is safe because Gradle minor upgrades are
# backwards-compatible for app builds and we use --no-daemon so no
# state leaks between runs.
- name: Ensure Gradle wrapper is recent enough for AGP
if: steps.detect-app.outputs.app_module != ''
run: |
WRAPPER=gradle/wrapper/gradle-wrapper.properties
if [ ! -f "$WRAPPER" ]; then
echo "No gradle wrapper found at $WRAPPER — skipping bump."
exit 0
fi
CURRENT=$(grep -oE 'gradle-[0-9.\-rcA-Za-z]+-(bin|all)\.zip' "$WRAPPER" | head -1)
echo "Current wrapper: $CURRENT"
# Cheap heuristic: detect AGP major from the catalog, pick a
# matching Gradle target. Falls back to 8.10.2 when unknown.
AGP=$(grep -h -oE 'agp[[:space:]]*=[[:space:]]*"[0-9.]+' gradle/libs.versions.toml 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)
AGP_MAJOR=$(echo "$AGP" | cut -d. -f1)
if [ "$AGP_MAJOR" = "9" ]; then
TARGET="9.0.0"
else
TARGET="8.10.2"
fi
echo "AGP detected: $AGP → target Gradle $TARGET"
sed -i -E "s#gradle-[0-9.\\-rcA-Za-z]+-(bin|all)\\.zip#gradle-${TARGET}-bin.zip#" "$WRAPPER"
grep distributionUrl "$WRAPPER"
- name: Build BEFORE APK
if: steps.detect-app.outputs.app_module != ''
continue-on-error: true
env:
APP_MOD: ${{ steps.detect-app.outputs.app_module }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Use merge-base (where the PR branch diverged from main) so we
# build the "true" before state, ignoring unrelated commits that
# landed on main after Dependabot opened the PR.
if [ -n "$BASE_SHA" ] && [ -n "$HEAD_SHA" ]; then
BEFORE_REF=$(git merge-base "$BASE_SHA" "$HEAD_SHA")
else
BEFORE_REF=HEAD
fi
echo "BEFORE_REF=$BEFORE_REF"
# Catalog can live in a sub-folder (e.g. shared/gradle/libs.versions.toml)
for cat in $(git ls-tree -r --name-only "$BEFORE_REF" | grep 'libs\.versions\.toml$'); do
git show "$BEFORE_REF:$cat" > "$cat" 2>/dev/null || true
done
build_ok=true
./gradlew "${APP_MOD}:assembleDebug" --no-daemon --console=plain --stacktrace 2>&1 | tee /tmp/before-build.log \
|| build_ok=false
APK=$(find . -name "*.apk" -path "*/debug/*" | head -1)
if [ -n "$APK" ]; then
cp "$APK" /tmp/before.apk
echo "BEFORE APK: $APK"
else
echo "::warning::BEFORE APK build failed — DroidBot will still try AFTER."
grep -E "^\* What went wrong|^> |error:|FAILURE:" /tmp/before-build.log | head -10 || true
fi
git checkout -- '**/libs.versions.toml' 2>/dev/null || git checkout -- gradle/libs.versions.toml 2>/dev/null || true
[ "$build_ok" = true ] && [ -n "$APK" ] || exit 1
- name: Build AFTER APK
if: steps.detect-app.outputs.app_module != ''
continue-on-error: true
env:
APP_MOD: ${{ steps.detect-app.outputs.app_module }}
run: |
build_ok=true
./gradlew clean "${APP_MOD}:assembleDebug" --no-daemon --console=plain --stacktrace 2>&1 | tee /tmp/after-build.log \
|| build_ok=false
APK=$(find . -name "*.apk" -path "*/debug/*" | head -1)
if [ -n "$APK" ]; then
cp "$APK" /tmp/after.apk
echo "AFTER APK: $APK"
else
echo "::warning::AFTER APK build failed — Dependabot's bump is likely incompatible with the project."
echo "DroidBot will mirror BEFORE as AFTER so Phase 3 still produces a trace."
grep -E "^\* What went wrong|^> |error:|FAILURE:" /tmp/after-build.log | head -10 || true
fi
[ "$build_ok" = true ] && [ -n "$APK" ] || exit 1
- name: Enable KVM
if: always()
continue-on-error: true
run: |
# Run the emulator if either APK is available — we want DroidBot
# to produce *some* trace even on incompatible bumps (e.g. when
# AFTER fails to compile, we still get the BEFORE walkthrough).
if [ ! -f /tmp/before.apk ] && [ ! -f /tmp/after.apk ]; then
echo "::warning::No BEFORE APK *and* no AFTER APK — emulator + DroidBot will be skipped."
exit 0
fi
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Write DroidBot script
if: always()
run: |
cat > /tmp/run-droidbot.sh <<'DBSCRIPT'
#!/usr/bin/env bash
set -x
# If NEITHER APK is present, there is literally nothing to explore.
# Otherwise we always run DroidBot on whatever side is available
# and mirror its output to the missing side so the merge job (and
# consequently the report) never sees an empty phase3 folder.
if [ ! -f /tmp/before.apk ] && [ ! -f /tmp/after.apk ]; then
echo "::error::No APKs produced — DroidBot has nothing to explore."
exit 0
fi
if [ -f /tmp/before.apk ]; then
echo "=== DroidBot on BEFORE APK ==="
droidbot -a /tmp/before.apk -o /tmp/droidbot-before \
-policy dfs_greedy -count 100 -timeout 90 \
-grant_perm -keep_env -is_emulator || true
fi
if [ -f /tmp/after.apk ]; then
echo "=== DroidBot on AFTER APK ==="
droidbot -a /tmp/after.apk -o /tmp/droidbot-after \
-policy dfs_greedy -count 100 -timeout 90 \
-grant_perm -keep_env -is_emulator || true
fi
# Mirror whichever side is missing so the consolidator has two
# comparable UTGs. This produces "0 diffs" rather than "skipped",
# which is the truthful outcome when a bump breaks compilation:
# the AFTER state is functionally identical to BEFORE because the
# AFTER APK never existed for the user to interact with.
if [ -d /tmp/droidbot-before ] && [ ! -d /tmp/droidbot-after ]; then
echo "=== AFTER APK missing — mirror BEFORE as the AFTER baseline ==="
cp -R /tmp/droidbot-before /tmp/droidbot-after
elif [ -d /tmp/droidbot-after ] && [ ! -d /tmp/droidbot-before ]; then
echo "=== BEFORE APK missing — mirror AFTER as the BEFORE baseline ==="
cp -R /tmp/droidbot-after /tmp/droidbot-before
fi
DBSCRIPT
chmod +x /tmp/run-droidbot.sh
- name: Run DroidBot on emulator
if: always()
continue-on-error: true
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
arch: x86_64
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
disable-animations: true
script: /tmp/run-droidbot.sh
- name: Package UTG artifact
if: always()
run: |
has_utg() {
[ -s "$1/utg.js" ] || [ -s "$1/utg.json" ]
}
mkdir -p /tmp/utg-bundle/phase3
if [ -d /tmp/droidbot-before ] && has_utg /tmp/droidbot-before; then
mkdir -p /tmp/utg-bundle/phase3/before-utg
cp -R /tmp/droidbot-before/. /tmp/utg-bundle/phase3/before-utg/
fi
if [ -d /tmp/droidbot-after ] && has_utg /tmp/droidbot-after; then
mkdir -p /tmp/utg-bundle/phase3/after-utg /tmp/utg-bundle/phase3/impact-utg
cp -R /tmp/droidbot-after/. /tmp/utg-bundle/phase3/after-utg/
cp -R /tmp/droidbot-after/. /tmp/utg-bundle/phase3/impact-utg/
fi
if [ -d /tmp/utg-bundle/phase3/before-utg ] && [ ! -d /tmp/utg-bundle/phase3/after-utg ]; then
cp -R /tmp/utg-bundle/phase3/before-utg /tmp/utg-bundle/phase3/after-utg
cp -R /tmp/utg-bundle/phase3/before-utg /tmp/utg-bundle/phase3/impact-utg
elif [ -d /tmp/utg-bundle/phase3/after-utg ] && [ ! -d /tmp/utg-bundle/phase3/before-utg ]; then
cp -R /tmp/utg-bundle/phase3/after-utg /tmp/utg-bundle/phase3/before-utg
fi
# include build logs so merge can surface failures
cp /tmp/before-build.log /tmp/utg-bundle/ 2>/dev/null || true
cp /tmp/after-build.log /tmp/utg-bundle/ 2>/dev/null || true
- uses: actions/upload-artifact@v4
if: always()
with:
name: droidbot-utg
path: /tmp/utg-bundle/
retention-days: 7
# ---------------------------------------------------------------------------
# Merge: fuse static + droidbot, rebuild report, post PR comment.
# ---------------------------------------------------------------------------
merge:
needs: [static-pipeline, droidbot]
if: always() && needs.static-pipeline.result == 'success'
runs-on: ubuntu-latest
outputs:
report_path: ${{ steps.pages-meta.outputs.report_path }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install system deps for cairosvg
run: |
sudo apt-get update -y
sudo apt-get install -y libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0
- name: Install analyzer + cairosvg
run: |
pip install ./tools/kmp-impact-analyzer
pip install "cairosvg==2.7.1"
- uses: actions/download-artifact@v4
with:
name: static-output
path: /tmp/output/
- uses: actions/download-artifact@v4
if: always()
continue-on-error: true
with:
name: droidbot-utg
path: /tmp/utg-bundle/
- name: Merge DroidBot UTGs into output
run: |
has_utg() {
[ -s "$1/utg.js" ] || [ -s "$1/utg.json" ]
}
if [ -d /tmp/utg-bundle/phase3 ]; then
mkdir -p /tmp/output/phase3
cp -R /tmp/utg-bundle/phase3/. /tmp/output/phase3/
echo "UTGs hydrated under /tmp/output/phase3"
else
echo "No UTG bundle available — Phase 3 will render placeholder."
fi
# Re-run consolidation with real UTG output so Phase 3 stats reflect it.
# We accept either before-utg or after-utg (or both); the run-droidbot.sh
# script mirrors whichever side is missing so a half-broken build still
# produces a comparable trace instead of silently skipping DroidBot.
if has_utg /tmp/output/phase3/before-utg || has_utg /tmp/output/phase3/after-utg; then
# Mirror if one side is missing — the Python consolidator needs both.
has_utg /tmp/output/phase3/before-utg || cp -R /tmp/output/phase3/after-utg /tmp/output/phase3/before-utg
has_utg /tmp/output/phase3/after-utg || cp -R /tmp/output/phase3/before-utg /tmp/output/phase3/after-utg
python3 - <<'PY'
from pathlib import Path
from kmp_impact_analyzer.config import AnalysisConfig
from kmp_impact_analyzer.contracts import ImpactGraph
from kmp_impact_analyzer.phase3_dynamic.droidbot_runner import run_dynamic_analysis
from kmp_impact_analyzer.phase4_consolidate.consolidator import run_consolidation
output = Path("/tmp/output")
impact_graph = ImpactGraph.model_validate_json(
(output / "phase2" / "impact_graph.json").read_text()
)
cfg = AnalysisConfig(
repo_path=".",
dependency_group=impact_graph.dependency_group,
before_version=impact_graph.version_before,
after_version=impact_graph.version_after,
output_dir=str(output),
skip_dynamic=False,
droidbot_before_output="/tmp/output/phase3/before-utg",
droidbot_after_output="/tmp/output/phase3/after-utg",
)
ui_regressions = run_dynamic_analysis(cfg)
(output / "phase3").mkdir(exist_ok=True)
(output / "phase3" / "ui_regressions.json").write_text(
ui_regressions.model_dump_json(indent=2)
)
consolidated = run_consolidation(impact_graph, ui_regressions, ".")
(output / "phase4" / "consolidated.json").write_text(
consolidated.model_dump_json(indent=2)
)
print(f"Phase 3 refreshed: {len(ui_regressions.before_screens)} before, "
f"{len(ui_regressions.after_screens)} after, "
f"{len(ui_regressions.diffs)} diffs")
PY
else
echo "::warning::DroidBot artifact did not contain utg.js/utg.json; marking Phase 3 as blocked."
python3 - <<'PY'
from pathlib import Path
from kmp_impact_analyzer.contracts import DynamicStatus, ImpactGraph, UIRegressions
from kmp_impact_analyzer.phase4_consolidate.consolidator import run_consolidation
output = Path("/tmp/output")
impact_graph = ImpactGraph.model_validate_json(
(output / "phase2" / "impact_graph.json").read_text()
)
ui_regressions = UIRegressions(
status=DynamicStatus.BLOCKED,
blocked_reason="DroidBot produced no UTG artifact",
)
(output / "phase3").mkdir(exist_ok=True)
(output / "phase3" / "ui_regressions.json").write_text(
ui_regressions.model_dump_json(indent=2)
)
consolidated = run_consolidation(impact_graph, ui_regressions, ".")
(output / "phase4" / "consolidated.json").write_text(
consolidated.model_dump_json(indent=2)
)
PY
fi
- name: Rebuild report + decorate the original DroidBot HTML
run: |
python3 - <<'PY'
from pathlib import Path
from kmp_impact_analyzer.contracts import ConsolidatedResult, ShadowManifest
from kmp_impact_analyzer.reporting.report_site import generate_report_site
from kmp_impact_analyzer.reporting.utg_decorate import colorize_droidbot_html
output = Path("/tmp/output")
consolidated = ConsolidatedResult.model_validate_json(
(output / "phase4" / "consolidated.json").read_text()
)
manifest = ShadowManifest.model_validate_json(
(output / "phase1" / "manifest.json").read_text()
)
generate_report_site(consolidated, manifest, output)
# Append our decorate script to DroidBot's own index.html so the
# iframe keeps the original Android screenshots inside each node
# while still rendering the relation-coloured borders.
for d in ("impact-utg", "after-utg", "after", "before-utg", "before"):
if colorize_droidbot_html(output / "phase3" / d, consolidated):
print(f"[utg] decorated phase3/{d}/index.html")
PY
- name: Generate report PNGs (sunburst, droidbot, codecity, tree)
run: |
python3 - <<'PY'
from pathlib import Path
import cairosvg
from kmp_impact_analyzer.contracts import ConsolidatedResult
from kmp_impact_analyzer.reporting.sunburst import (
build_sunburst_svg_standalone, build_tree_svg,
)
from kmp_impact_analyzer.reporting.codecity_image import build_codecity_svg
from kmp_impact_analyzer.reporting.utg_image import build_utg_svg
output = Path("/tmp/output")
report = output / "report"
c = ConsolidatedResult.model_validate_json(
(output / "phase4" / "consolidated.json").read_text()
)
# Pick the first DroidBot output that actually exists.
utg_dir = next(
(output / "phase3" / d for d in (
"after-utg", "after", "before-utg", "before", "impact-utg"
) if (output / "phase3" / d / "utg.js").exists()),
output / "phase3",
)
# All sunburst sub-views are server-rendered SVG (no Playwright,
# no D3 runtime), so the PR-comment images are deterministic and
# always match the report's standalone Python renderer.
# The Escala/legend is now baked into the sunburst SVG itself —
# we no longer ship a separate sunburst-legend.png in the comment.
svgs = {
"sunburst": build_sunburst_svg_standalone(c),
"sunburst-tree": build_tree_svg(c),
"droidbot": build_utg_svg(c, utg_dir),
"codecity": build_codecity_svg(c),
}
for name, svg in svgs.items():
(report / f"{name}.svg").write_text(svg, encoding="utf-8")
try:
cairosvg.svg2png(
bytestring=svg.encode("utf-8"),
write_to=str(report / f"{name}.png"),
output_width=900,
)
size = (report / f'{name}.png').stat().st_size
print(f"{name}.png: {size} bytes")
except Exception as e:
print(f"WARN: failed to rasterise {name}: {e}")
PY
- name: Compute report path
id: pages-meta
run: |
python3 - <<'PY'
import json, os, re
from pathlib import Path
s = json.loads(Path("/tmp/output/report/summary.json").read_text())
slug = lambda v: re.sub(r"[^a-zA-Z0-9]+", "-", v.strip().lower()).strip("-") or "unknown"
rp = f"reports/{slug(s.get('dependency_group','unknown'))}/{slug(s.get('version_before','b'))}-to-{slug(s.get('version_after','a'))}/run-{os.environ['GITHUB_RUN_ID']}"
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write(f"report_path={rp}\n")
PY
# Real PNGs of the live HTML viewers (Cytoscape + CodeCharta WebGL).
# These replace the synthetic SVG placeholders in the PR comment so
# reviewers see the same visualisations the report shows.
- name: Install Playwright + Chromium
run: |
pip install "playwright==1.48.0"
python -m playwright install --with-deps chromium
- name: Capture real DroidBot + CodeCharta + Sunburst screenshots
continue-on-error: true
run: |
python3 - <<'PY'
from pathlib import Path
from kmp_impact_analyzer.contracts import ConsolidatedResult
from kmp_impact_analyzer.reporting.screenshots import take_screenshots
output = Path("/tmp/output")
consolidated = ConsolidatedResult.model_validate_json(
(output / "phase4" / "consolidated.json").read_text()
)
# Pass the consolidated result so the DroidBot screenshot can run our
# impact-decorator JS via page.evaluate() before the snapshot, even
# if the iframe's inline copy of the script is racy.
saved = take_screenshots(output, consolidated=consolidated)
for k, v in saved.items():
print(f" {k}: {v}")
PY
- name: Verify PR-comment screenshots actually landed on disk
run: |
set +e
report=/tmp/output/report
missing=0
for f in droidbot-real.png codecharta-real.png sunburst.png sunburst-tree.png; do
if [ -s "$report/$f" ]; then
size=$(stat -c%s "$report/$f")
echo " OK $f ($size bytes)"
else
echo " -- $f not produced (PR comment will fall back to SVG-rasterised image)"
missing=$((missing+1))
fi
done
echo "missing=$missing"
- name: Build site bundle
run: |
set -euo pipefail
RP="${{ steps.pages-meta.outputs.report_path }}"
mkdir -p "site/$RP"
cp -R /tmp/output/report/. "site/$RP/"
for p in phase1 phase2 phase3 phase4 phase5 gitgraph sbom; do
if [ -d "/tmp/output/$p" ]; then
cp -R "/tmp/output/$p" "site/$RP/"
fi
done
- run: cat /tmp/output/report/summary.md >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: impact-report-site
path: site/
retention-days: 30
- name: Comment PR
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
REPORT_PATH: ${{ steps.pages-meta.outputs.report_path }}
REPO_NAME: ${{ github.event.repository.name }}
REPO_OWNER: ${{ github.repository_owner }}
with:
# The PR comment intentionally does NOT include the static propagation
# SVG or the GitGraph block any more (the user removed them after v9
# because the SBOM block always reported "no changes" for version
# bumps). Instead we embed:
# 1. risk + KPIs + percentages
# 2. stack-compatibility warnings, when applicable
# 3. propagation tree (markdown) — colour-coded by relation
# 4. DroidBot UTG with relation-coloured borders (PNG)
# 5. CodeCity treemap with three impact levels (PNG)
# 6. source-set sunburst (PNG)
script: |
const fs = require('fs');
const base = `https://${process.env.REPO_OWNER}.github.io/${process.env.REPO_NAME}`;
const reportUrl = `${base}/${process.env.REPORT_PATH}/`;
const img = name => `${reportUrl}${name}.png`;
const s = JSON.parse(fs.readFileSync('/tmp/output/report/summary.json', 'utf8'));
const stripEmoji = txt => txt.replace(
/[\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2300}-\u{23FF}\u{2B00}-\u{2BFF}️]/gu,
'',
).replace(/ {2,}/g, ' ');
const pct = (part, total) => total > 0 ? `${Math.round((part/total)*100)}%` : '—';
const impactedFiles = s.total_impacted_files ?? 0;
const totalFiles = s.total_project_files ?? 0;
const lines = [
'<!-- impact-analysis-bot -->',
`Bumps \`${s.dependency_group}\` from \`${s.version_before}\` to \`${s.version_after}\`.`,
'',
'### Dependabot impact companion (kmp-impact-analyzer)',
'',
`- **Risk:** **${(s.risk_level || 'low').toUpperCase()}**`,
`- **Impacted files:** ${impactedFiles} / ${totalFiles} (${pct(impactedFiles, totalFiles)}) — ${s.direct_impacts ?? 0} direct, ${s.transitive_impacts ?? 0} transitive`,
`- **Impacted screens:** ${s.total_impacted_screens ?? 0}`,
`- **Expect/Actual pairs:** ${s.expect_actual_pairs_total ?? 0} (${s.expect_actual_impacts ?? 0} affected by this PR)`,
`- **Dynamic analysis:** ${stripEmoji(s.dynamic_summary || '').trim()}`,
`- **Recommendation:** ${stripEmoji(s.recommendation || '').trim()}`,
'',
];
// Stack compatibility warnings — surfaced in the comment so the
// reviewer sees them before merging without opening the report.
const compat = s.compat_warnings || [];
if (compat.length) {
lines.push('### Stack compatibility');
lines.push('');
for (const w of compat) {
lines.push(`> **${w.title}** — ${w.detail}`);
lines.push(`> Suggested action: ${w.suggestion}`);
lines.push('');
}
}
// The DroidBot and CodeCharta PNGs come from Playwright; the
// sunburst (circle / legend / tree) PNGs are server-rendered SVG
// rasterised by cairosvg — see the "Generate report PNGs" step.
const fsExists = name => {
try { return fs.existsSync(`/tmp/output/report/${name}.png`); }
catch (_) { return false; }
};
const droidbotImg = fsExists('droidbot-real') ? 'droidbot-real' : 'droidbot';
const codeImg = fsExists('codecharta-real') ? 'codecharta-real' : 'codecity';
lines.push('### DroidBot — UI Transition Graph (impact colours)');
lines.push('');
lines.push(`![DroidBot UTG with impact colours](${img(droidbotImg)})`);
lines.push('');
lines.push('### Source-set sunburst');
lines.push('');
lines.push(`![Sunburst](${img('sunburst')})`);
lines.push('');
lines.push('### Propagation tree');
lines.push('');
lines.push(`![Propagation tree](${img('sunburst-tree')})`);
lines.push('');
lines.push('### CodeCharta — 3D code city');
lines.push('');
lines.push(`![CodeCharta city](${img(codeImg)})`);
lines.push('');
lines.push('---', '');
lines.push(`**Full interactive report:** ${reportUrl}`);
const body = lines.join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(
c => c.body && c.body.includes('<!-- impact-analysis-bot -->')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body,
});
}
# ---------------------------------------------------------------------------
deploy-pages:
needs: merge
if: always() && needs.merge.result == 'success'
runs-on: ubuntu-latest
concurrency:
group: impact-pages-deploy
cancel-in-progress: false
steps:
- uses: actions/download-artifact@v4
with:
name: impact-report-site
path: new-report/
- uses: actions/checkout@v4
with:
ref: gh-pages-history
path: pages-payload
fetch-depth: 1
continue-on-error: true
- run: "[ -d pages-payload ] || mkdir -p pages-payload"
- name: Merge into cumulative site
env:
REPORT_PATH: ${{ needs.merge.outputs.report_path }}
REPO_NAME: ${{ github.event.repository.name }}
REPO_OWNER: ${{ github.repository_owner }}
run: |
set -euo pipefail
mkdir -p "$(dirname "pages-payload/$REPORT_PATH")"
cp -R new-report/"$REPORT_PATH" pages-payload/"$REPORT_PATH"
python3 - <<'PY'
import html, json, os
from pathlib import Path
root = Path('pages-payload')
rn, ro = os.environ['REPO_NAME'], os.environ['REPO_OWNER']
bp = f"/{rn}" if rn != f"{ro}.github.io" else ""
crp = os.environ['REPORT_PATH']
lh = f"{bp}/{crp}/"
reports = []
for f in sorted(root.glob('reports/*/*/run-*/index.html'), reverse=True):
rp = str(f.parent.relative_to(root))
sp = f.parent / 'summary.json'
d = b = a = r = 'unknown'
if sp.exists():
s = json.loads(sp.read_text())
d = s.get('dependency_group', d)
b = s.get('version_before', b)
a = s.get('version_after', a)
r = s.get('risk_level', r)
reports.append(dict(p=rp, d=d, b=b, a=a, r=r))
e = lambda v: html.escape(str(v), quote=True)
rows = '\n'.join(
f'<tr><td><a href="{e(bp+"/"+i["p"]+"/")}">{e(i["p"].split("/")[-1])}</a></td>'
f'<td>{e(i["d"])}</td><td>{e(i["b"])}</td><td>{e(i["a"])}</td>'
f'<td>{e(i["r"])}</td></tr>'
for i in reports
) or '<tr><td colspan="5">No reports.</td></tr>'
(root/'index.html').write_text(
'<!doctype html><html lang="en"><head><meta charset="utf-8"/>'
'<title>Impact Reports</title>'
'<style>body{font-family:system-ui;margin:2rem auto;max-width:1000px;padding:0 1rem}'
'table{border-collapse:collapse;width:100%;margin:1.5rem 0}'
'th,td{border:1px solid #e5e7eb;padding:.65rem;text-align:left}'
'th{background:#f9fafb}.c{background:#f9fafb;border:1px solid #e5e7eb;'
'border-radius:.5rem;padding:1rem;margin:1rem 0}</style></head><body>'
'<h1>Dependency Impact Analysis Reports</h1>'
f'<div class="c"><strong>Latest:</strong> <a href="{e(lh)}">{e(crp)}</a></div>'
'<table><thead><tr><th>Run</th><th>Dependency</th>'
'<th>Before</th><th>After</th><th>Risk</th></tr></thead>'
f'<tbody>{rows}</tbody></table></body></html>'
)
ld = root/'reports'/'latest'
ld.mkdir(parents=True, exist_ok=True)
(ld/'index.html').write_text(
f'<!doctype html><html><head>'
f'<meta http-equiv="refresh" content="0;url={e(lh)}"/></head>'
f'<body><a href="{e(lh)}">Latest</a></body></html>'
)
PY
- name: Persist history
run: |
cd pages-payload
rm -rf .git
git init
git checkout -b gh-pages-history
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add .
git commit -m "Publish ${{ needs.merge.outputs.report_path }}"
git push --force "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git" gh-pages-history
- uses: actions/upload-pages-artifact@v4
with:
path: pages-payload/
- uses: actions/deploy-pages@v4