diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..f39a56a --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,32 @@ +# actionlint configuration. +# +# One suppression, scoped to three files and three property names. +# +# `job.workflow_repository`, `job.workflow_sha` and `job.workflow_file_path` are +# real GitHub Actions contexts. They are the only way a called reusable workflow +# can name *itself*: inside a reusable, every `github.*` value describes the +# CALLER, so `github.workflow_ref` and `github.workflow_sha` identify the calling +# workflow, not the called one. The three SDK reusables record the callee triple +# in their runtime receipt so the provenance in that receipt is the workflow that +# actually ran. +# +# actionlint v1.7.12 -- the current release, and the version `actionlint.yml` +# pins -- models the `job` context as only +# `{check_run_id, container, services, status}` and so reports these three as +# undefined properties. Upstream has no issue or commit for them. +# +# Proven to exist rather than assumed: a probe reusable pinned at an absolute +# commit and called from a different commit reported +# `job.workflow_sha=39f043f016b382760a566f9a2a3189c47b8ef74b` (the callee) while +# `github.sha=f40c3f0a63e7d29ffe07864615ae7bcd5e16f2b8` (the caller), in run +# https://github.com/NDDev-it-com/ci-workflows/actions/runs/31779014883. +# +# The suppression names the three properties explicitly, so a typo such as +# `job.workflow_shaa` is still reported. `check_actionlint_contract.py` +# additionally proves this file is still load-bearing and still narrow: it runs +# actionlint without the config and fails if these errors have stopped +# appearing, which is how this file gets deleted once upstream catches up. +paths: + .github/workflows/{dart-flutter-ci,kotlin-android-ci,qt-ci}.yml: + ignore: + - '^property "workflow_(file_path|repository|sha)" is not defined in object type \{check_run_id: number; container: .+\}$' diff --git a/.github/workflows/dart-flutter-ci.yml b/.github/workflows/dart-flutter-ci.yml index a1c1d94..363ddaa 100644 --- a/.github/workflows/dart-flutter-ci.yml +++ b/.github/workflows/dart-flutter-ci.yml @@ -6,6 +6,10 @@ name: dart-flutter-ci on: workflow_call: + outputs: + evidence: + description: 'Redacted JSON receipt for the resolved SDK and successful default lanes.' + value: ${{ jobs.dart-flutter.outputs.evidence }} inputs: runner: type: string @@ -54,6 +58,8 @@ jobs: timeout-minutes: ${{ inputs.timeout_minutes }} permissions: contents: read + outputs: + evidence: ${{ steps.evidence.outputs.evidence }} defaults: run: # Explicit, and not redundant with the workflow-level default above: @@ -70,6 +76,7 @@ jobs: persist-credentials: false - name: Set up Flutter + id: flutter uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: channel: ${{ inputs.flutter_channel }} @@ -94,6 +101,83 @@ jobs: env: TEST_COMMAND: ${{ inputs.test_command }} FLUTTER_CHANNEL: ${{ inputs.flutter_channel }} + TEST_LOG: ${{ runner.temp }}/dart-flutter-tests.log run: | - bash -euo pipefail -c "$TEST_COMMAND" + set -euo pipefail + bash -euo pipefail -c "$TEST_COMMAND" 2>&1 | tee "$TEST_LOG" echo "Flutter ${FLUTTER_CHANNEL} format + analyze + tests passed." >> "$GITHUB_STEP_SUMMARY" + + - name: Emit runtime evidence + id: evidence + env: + CACHE_KEY: ${{ steps.flutter.outputs.CACHE-KEY }} + CALLEE_PATH: ${{ job.workflow_file_path }} + CALLEE_REPOSITORY: ${{ job.workflow_repository }} + CALLEE_SHA: ${{ job.workflow_sha }} + FLUTTER_ARCH: ${{ steps.flutter.outputs.ARCHITECTURE }} + FLUTTER_CHANNEL: ${{ steps.flutter.outputs.CHANNEL }} + FLUTTER_VERSION: ${{ steps.flutter.outputs.VERSION }} + PUB_CACHE_KEY: ${{ steps.flutter.outputs.PUB-CACHE-KEY }} + PUB_GET_COMMAND: ${{ inputs.pub_get_command }} + TEST_COMMAND: ${{ inputs.test_command }} + TEST_LOG: ${{ runner.temp }}/dart-flutter-tests.log + run: | + python3 -I <<'PY' + import hashlib, json, os, pathlib, re, subprocess + + version = json.loads(subprocess.check_output( + ["flutter", "--version", "--machine"], text=True)) + + # Provenance of the workflow that is running, not of the caller's tree. + # `github.*` inside a called reusable is bound to the CALLER, so hashing + # `${github.workspace}/.github/workflows/dart-flutter-ci.yml` digests a + # file that belongs to the caller. `job.workflow_*` is bound to the + # callee and supplied by the runner, so the repository/sha/path triple + # names these exact bytes and no caller can forge it. + receipt = { + "callee_path": os.environ["CALLEE_PATH"], + "callee_repository": os.environ["CALLEE_REPOSITORY"], + "callee_sha": os.environ["CALLEE_SHA"], + "caller_repository": os.environ["GITHUB_REPOSITORY"], + "caller_sha": os.environ["GITHUB_SHA"], + "dart_version": version["dartSdkVersion"].split()[0], + "flutter_arch": os.environ["FLUTTER_ARCH"], + "flutter_channel": os.environ["FLUTTER_CHANNEL"], + "flutter_revision": version["frameworkRevision"], + "flutter_version": os.environ["FLUTTER_VERSION"], + "kind": "flutter", + "os": os.environ["RUNNER_OS"], + "runner_arch": os.environ["RUNNER_ARCH"], + "schema_version": 1, + } + sections = ["toolchain"] + if os.environ["CACHE_KEY"] and os.environ["PUB_CACHE_KEY"]: + sections.append("cache") + receipt["cache_key"] = os.environ["CACHE_KEY"] + receipt["pub_cache_key"] = os.environ["PUB_CACHE_KEY"] + + # `pub_get_command` is documented "Empty to skip", so a caller that + # skips resolution has no reason to own a `pubspec.lock` -- reading one + # unconditionally turned a documented option into a crash. + lock = pathlib.Path("pubspec.lock") + if os.environ["PUB_GET_COMMAND"] and lock.is_file(): + sections.append("resolve") + receipt["pub_get_command"] = os.environ["PUB_GET_COMMAND"] + receipt["pubspec_lock_sha256"] = hashlib.sha256(lock.read_bytes()).hexdigest() + + log = pathlib.Path(os.environ["TEST_LOG"]) + if os.environ["TEST_COMMAND"] and log.is_file(): + counts = [int(value) for value in re.findall( + r"\+(\d+)", log.read_text(encoding="utf-8"))] + sections.append("test") + receipt["test_command"] = os.environ["TEST_COMMAND"] + receipt["test_count"] = max(counts, default=0) + receipt["test_log_sha256"] = hashlib.sha256(log.read_bytes()).hexdigest() + + receipt["sections"] = sorted(sections) + encoded = json.dumps(receipt, sort_keys=True, separators=(",", ":")) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"evidence={encoded}\n") + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: + summary.write(f"\nRuntime evidence: `{encoded}`\n") + PY diff --git a/.github/workflows/kotlin-android-ci.yml b/.github/workflows/kotlin-android-ci.yml index edc37ed..fcf4a35 100644 --- a/.github/workflows/kotlin-android-ci.yml +++ b/.github/workflows/kotlin-android-ci.yml @@ -6,6 +6,10 @@ name: kotlin-android-ci on: workflow_call: + outputs: + evidence: + description: 'Redacted JSON receipt for the JDK, Gradle, Android and successful build lanes.' + value: ${{ jobs.kotlin-android.outputs.evidence }} inputs: runner: type: string @@ -52,6 +56,8 @@ jobs: timeout-minutes: ${{ inputs.timeout_minutes }} permissions: contents: read + outputs: + evidence: ${{ steps.evidence.outputs.evidence }} defaults: run: # Explicit, and not redundant with the workflow-level default above: @@ -74,7 +80,10 @@ jobs: distribution: ${{ inputs.java_distribution }} - name: Set up Gradle + id: gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-provider: basic - name: Set up Android SDK if: ${{ inputs.setup_android }} @@ -92,6 +101,256 @@ jobs: env: BUILD_COMMAND: ${{ inputs.build_command }} JAVA_VER: ${{ inputs.java_version }} + BUILD_LOG: ${{ runner.temp }}/kotlin-android-build.log run: | - bash -euo pipefail -c "$BUILD_COMMAND" + set -euo pipefail + bash -euo pipefail -c "$BUILD_COMMAND" 2>&1 | tee "$BUILD_LOG" echo "Kotlin/Android (JDK ${JAVA_VER}) build passed." >> "$GITHUB_STEP_SUMMARY" + + - name: Emit runtime evidence + id: evidence + env: + BUILD_COMMAND: ${{ inputs.build_command }} + BUILD_LOG: ${{ runner.temp }}/kotlin-android-build.log + CALLEE_PATH: ${{ job.workflow_file_path }} + CALLEE_REPOSITORY: ${{ job.workflow_repository }} + CALLEE_SHA: ${{ job.workflow_sha }} + JAVA_VERSION_INPUT: ${{ inputs.java_version }} + SETUP_ANDROID: ${{ inputs.setup_android }} + run: | + python3 -I <<'PY' + import hashlib, json, os, pathlib, re, shutil, stat, subprocess, sys + import xml.etree.ElementTree as ET + + untrusted = [] + # GitHub-hosted runners ship a world-writable tool cache, so mode bits + # there name no other principal: the VM is single-tenant and destroyed + # with the job. On any other runner the bits mean what they say, and a + # writable toolchain root is refused. `RUNNER_ENVIRONMENT` is set by + # the runner itself, so the caller cannot assert the weaker model. + EPHEMERAL = os.environ.get("RUNNER_ENVIRONMENT") == "github-hosted" + + def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + def trusted_root(raw, label): + """Resolve a root, or record why it is not vouched for and return None. + + Returning None rather than exiting is deliberate. This workflow is a + generic reusable: a consumer whose runner image happens not to ship + an Android SDK must still get a green build and an honest receipt + that simply makes no claim about a root it could not verify. Every + refusal is named in `untrusted_roots`, so "absent" and "present but + untrustworthy" stay distinguishable and an observer can require the + list to be empty. + """ + if not raw: + return None + path = pathlib.Path(raw) + if not path.is_absolute() or not path.is_dir() or path.is_symlink(): + untrusted.append(f"{label}: not an absolute non-symlink directory") + return None + if sys.platform == "darwin" and path.parts[:2] == ("/", "var"): + candidate = pathlib.Path("/private").joinpath(*path.parts[1:]) + if not candidate.exists() or not os.path.samefile(path, candidate): + untrusted.append(f"{label}: incoherent Darwin /var alias") + return None + path = candidate + resolved = path.resolve(strict=True) + if resolved != path: + untrusted.append(f"{label}: untrusted ancestor alias") + return None + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open("/", flags) + try: + for component in resolved.parts[1:]: + child = os.open(component, flags, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + opened = os.fstat(descriptor) + current = resolved.stat() + if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino): + untrusted.append(f"{label}: identity changed during validation") + return None + finally: + os.close(descriptor) + if opened.st_uid not in {0, os.getuid()}: + untrusted.append(f"{label}: unowned uid {opened.st_uid}") + return None + if not EPHEMERAL and opened.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + untrusted.append( + f"{label}: group/world writable (mode {opened.st_mode & 0o7777:04o})") + return None + return resolved + + required = {"java.home", "java.runtime.version", "java.vendor", "java.version", "java.vm.name"} + + def java_identity(executable): + output = subprocess.check_output( + [str(executable), "-XshowSettings:properties", "-version"], + stderr=subprocess.STDOUT, text=True) + identity = dict(re.findall( + r"^\s+(java\.(?:home|runtime\.version|vendor|version|vm\.name)) = (.+)$", + output, re.MULTILINE)) + return identity if set(identity) == required else None + + # Provenance of the workflow that is running, not of the caller's tree. + # `github.*` inside a called reusable is bound to the CALLER, so hashing + # `${github.workspace}/.github/workflows/kotlin-android-ci.yml` digests + # whatever the caller keeps at that path -- or fails when they keep + # nothing there. `job.workflow_*` is bound to the callee and supplied by + # the runner, so the triple names these exact bytes unforgeably. + receipt = { + "callee_path": os.environ["CALLEE_PATH"], + "callee_repository": os.environ["CALLEE_REPOSITORY"], + "callee_sha": os.environ["CALLEE_SHA"], + "caller_repository": os.environ["GITHUB_REPOSITORY"], + "caller_sha": os.environ["GITHUB_SHA"], + "cache_provider": "basic", + "kind": "android", + "os": os.environ["RUNNER_OS"], + "runner_arch": os.environ["RUNNER_ARCH"], + "java_version_input": os.environ["JAVA_VERSION_INPUT"], + "root_trust_model": ( + "ephemeral-single-tenant-runner" if EPHEMERAL else "exclusive-filesystem" + ), + "schema_version": 1, + "setup_android": os.environ["SETUP_ANDROID"], + } + sections = [] + + build_log = pathlib.Path(os.environ["BUILD_LOG"]) + if build_log.is_file(): + sections.append("build") + receipt["build_command"] = os.environ["BUILD_COMMAND"] + receipt["build_log_sha256"] = digest(build_log) + # `[^ ]` matches a newline, so the previous pattern ran past the + # end of each line and produced entries like ":app:build\n>". + receipt["task_graph"] = list(dict.fromkeys(re.findall( + r"^> Task (:\S+)", build_log.read_text(encoding="utf-8"), re.MULTILINE))) + + prop = java_identity("java") + if prop is None: + raise SystemExit("the JDK selected by setup-java reports no usable identity") + requested = os.environ["JAVA_VERSION_INPUT"] + "." + if not prop["java.version"].startswith(requested) \ + or not prop["java.runtime.version"].startswith(requested): + raise SystemExit("the JDK on PATH does not satisfy the requested java_version") + # A section is claimed only when every field in it is real. If the JDK + # root cannot be vouched for, the receipt records why in + # `untrusted_roots` and makes no JDK claim, rather than shipping an + # empty string that reads like a verified answer. + observed_java_home = trusted_root(prop["java.home"], "observed JAVA_HOME") + if observed_java_home is not None: + sections.append("jdk") + receipt.update({ + "java_home_resolved": str(observed_java_home), + "java_runtime_version_resolved": prop["java.runtime.version"], + "java_vendor_resolved": prop["java.vendor"], + "java_version_resolved": prop["java.version"], + "java_vm_name_resolved": prop["java.vm.name"], + }) + + # Gradle identity is a section, not a precondition. `build_command` is + # caller-supplied and need not involve Gradle at all, and a project may + # legitimately have no wrapper; neither is this workflow's business to + # refuse. `./gradlew --version` unconditionally made the wrapper + # mandatory for every consumer. + launcher_argv = None + wrapper = pathlib.Path("./gradlew") + if wrapper.is_file() and os.access(wrapper, os.X_OK): + launcher_argv = ["./gradlew"] + elif shutil.which("gradle"): + launcher_argv = ["gradle"] + if launcher_argv is not None: + gradle = subprocess.check_output( + [*launcher_argv, "--version", "--console", "plain"], text=True) + gradle_version = re.search(r"^Gradle (\S+)$", gradle, re.MULTILINE) + launcher = re.search(r"^Launcher JVM:\s+(.+)$", gradle, re.MULTILINE) + daemon = re.search(r"^Daemon JVM:\s+(.+)$", gradle, re.MULTILINE) + launcher_identity = re.fullmatch( + r"(\S+) \((.+)\)", launcher.group(1).strip()) if launcher else None + daemon_home = trusted_root( + daemon.group(1).strip().split(" (", 1)[0], "Gradle daemon JAVA_HOME", + ) if daemon else None + daemon_prop = java_identity(daemon_home / "bin/java") if daemon_home else None + if gradle_version and launcher_identity and daemon and daemon_prop: + launcher_version = launcher_identity.group(1) + sections.append("gradle") + receipt.update({ + "gradle_daemon_jvm_home_resolved": daemon_prop["java.home"], + "gradle_daemon_jvm_resolved": daemon.group(1).strip(), + "gradle_daemon_jvm_runtime_version_resolved": daemon_prop["java.runtime.version"], + "gradle_daemon_jvm_vendor_resolved": daemon_prop["java.vendor"], + "gradle_daemon_jvm_version_resolved": daemon_prop["java.version"], + "gradle_daemon_jvm_vm_name_resolved": daemon_prop["java.vm.name"], + "gradle_launcher_jvm_resolved": launcher.group(1).strip(), + "gradle_launcher_jvm_runtime_version_resolved": prop["java.runtime.version"], + "gradle_launcher_jvm_vendor_resolved": launcher_identity.group(2).removesuffix( + " " + prop["java.runtime.version"]), + "gradle_launcher_jvm_version_resolved": launcher_version, + "gradle_launcher_jvm_vm_name_resolved": prop["java.vm.name"], + "gradle_version": gradle_version.group(1), + }) + else: + untrusted.append("Gradle launcher/daemon JVM identity is unreadable") + + reports = sorted(pathlib.Path(".").glob("**/test-results/*/TEST-*.xml")) + if reports: + sections.append("tests") + receipt["test_count"] = sum( + int(ET.parse(path).getroot().attrib.get("tests", "0")) for path in reports) + + # Everything below is a property of the caller's project, so each is a + # section that appears when the project has it. Requiring an APK, an + # `app` module, a dependency lock and a `verification-metadata.xml` + # unconditionally contradicted this workflow's own promise to work "for + # pure-JVM Kotlin and Android" -- a pure-JVM build produces no APK, and + # most projects have no verification metadata at all. + apks = sorted(pathlib.Path(".").glob("**/build/outputs/apk/**/*.apk")) + if apks: + sections.append("artifacts") + receipt["apk_sha256"] = [digest(path) for path in apks] + locks = sorted(pathlib.Path(".").glob("**/*.lockfile")) + if locks: + sections.append("locks") + receipt["lock_sha256"] = {path.as_posix(): digest(path) for path in locks} + metadata = pathlib.Path("gradle/verification-metadata.xml") + if metadata.is_file(): + sections.append("dependency_verification") + receipt["verification_metadata_sha256"] = digest(metadata) + wrapper_jar = pathlib.Path("gradle/wrapper/gradle-wrapper.jar") + wrapper_props = pathlib.Path("gradle/wrapper/gradle-wrapper.properties") + if wrapper_jar.is_file() and wrapper_props.is_file(): + sections.append("wrapper") + receipt["wrapper_jar_sha256"] = digest(wrapper_jar) + receipt["wrapper_properties_sha256"] = digest(wrapper_props) + + # The Android SDK is present only when the caller asked for it or the + # runner image already had it. Absent is a fact about the caller, not a + # failure of this workflow. + android_home = trusted_root(os.environ.get("ANDROID_HOME", ""), "ANDROID_HOME") + android_sdk_root = trusted_root(os.environ.get("ANDROID_SDK_ROOT", ""), "ANDROID_SDK_ROOT") + if android_home and android_sdk_root: + if android_home != android_sdk_root: + untrusted.append("ANDROID_HOME and ANDROID_SDK_ROOT disagree") + else: + sections.append("android_sdk") + receipt.update({ + "android_sdk_root_resolved": str(android_sdk_root), + "sdk_build_tools": sorted( + path.name for path in (android_sdk_root / "build-tools").glob("*") + if path.is_dir()), + "sdk_platforms": sorted( + path.name for path in (android_sdk_root / "platforms").glob("android-*") + if path.is_dir()), + }) + + receipt["sections"] = sorted(sections) + receipt["untrusted_roots"] = sorted(untrusted) + encoded = json.dumps(receipt, sort_keys=True, separators=(",", ":")) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"evidence={encoded}\n") + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: + summary.write(f"\nRuntime evidence: `{encoded}`\n") + PY diff --git a/.github/workflows/qt-ci.yml b/.github/workflows/qt-ci.yml index a7eb4e9..e952940 100644 --- a/.github/workflows/qt-ci.yml +++ b/.github/workflows/qt-ci.yml @@ -6,6 +6,10 @@ name: qt-ci on: workflow_call: + outputs: + evidence: + description: 'Redacted JSON receipt for the Qt/CMake toolchain and successful default lanes.' + value: ${{ jobs.qt.outputs.evidence }} inputs: runner: type: string @@ -68,6 +72,8 @@ jobs: timeout-minutes: ${{ inputs.timeout_minutes }} permissions: contents: read + outputs: + evidence: ${{ steps.evidence.outputs.evidence }} defaults: run: # Explicit, and not redundant with the workflow-level default above: @@ -92,6 +98,9 @@ jobs: arch: ${{ inputs.qt_arch }} modules: ${{ inputs.qt_modules }} cache: true + cache-key-prefix: qt-ci-v1 + aqtversion: '==3.3.0' + py7zrversion: '==1.0.0' - name: Set up ccache if: ${{ inputs.enable_ccache }} @@ -115,7 +124,94 @@ jobs: if: ${{ inputs.test_command != '' }} env: TEST_COMMAND: ${{ inputs.test_command }} - run: bash -euo pipefail -c "$TEST_COMMAND" + TEST_LOG: ${{ runner.temp }}/qt-tests.log + run: | + set -euo pipefail + bash -euo pipefail -c "$TEST_COMMAND" 2>&1 | tee "$TEST_LOG" + + - name: Emit runtime evidence + id: evidence + env: + BUILD_COMMAND: ${{ inputs.build_command }} + CALLEE_PATH: ${{ job.workflow_file_path }} + CALLEE_REPOSITORY: ${{ job.workflow_repository }} + CALLEE_SHA: ${{ job.workflow_sha }} + CONFIGURE_COMMAND: ${{ inputs.configure_command }} + QT_VERSION_INPUT: ${{ inputs.qt_version }} + TEST_COMMAND: ${{ inputs.test_command }} + TEST_LOG: ${{ runner.temp }}/qt-tests.log + run: | + python3 -I <<'PY' + import hashlib, json, os, pathlib, re, shutil, subprocess + + def tool(argv, pattern=None): + """Report a tool identity, or None when the tool is not installed.""" + if shutil.which(argv[0]) is None: + return None + out = subprocess.check_output(argv, text=True) + return out.splitlines()[0].strip() if pattern is None else out.strip() + + # Provenance of the workflow that is running, not of the caller's tree. + # `github.*` in a called reusable is bound to the CALLER, so hashing + # `${github.workspace}/.github/workflows/qt-ci.yml` digests whichever + # file the caller happens to have at that path -- or fails outright. + # `job.workflow_*` is bound to the callee and comes from the runner, so + # the repository/sha/path triple names these exact bytes and a caller + # cannot forge it. + receipt = { + "callee_path": os.environ["CALLEE_PATH"], + "callee_repository": os.environ["CALLEE_REPOSITORY"], + "callee_sha": os.environ["CALLEE_SHA"], + "caller_repository": os.environ["GITHUB_REPOSITORY"], + "caller_sha": os.environ["GITHUB_SHA"], + "kind": "qt", + "os": os.environ["RUNNER_OS"], + "runner_arch": os.environ["RUNNER_ARCH"], + "schema_version": 1, + } + sections = ["toolchain"] + receipt["cache_key_prefix"] = "qt-ci-v1" + receipt["qt_version_input"] = os.environ["QT_VERSION_INPUT"] + aqt = tool(["aqt", "version"]) + if aqt is not None: + receipt["aqt_version"] = aqt + cmake = tool(["cmake", "--version"]) + if cmake is not None: + receipt["cmake_version"] = cmake + qt_version = tool(["qmake", "-query", "QT_VERSION"], pattern=True) + if qt_version is not None: + receipt["qt_version"] = qt_version + + # Each block is conditional on the same input that produced the step it + # describes. `configure_command`, `build_command` and `test_command` are + # each documented "Empty to skip", so a caller that skips one must get a + # receipt without that section -- not an unhandled FileNotFoundError on + # a log the skipped step never wrote. + if os.environ["CONFIGURE_COMMAND"]: + sections.append("configure") + receipt["configure_command"] = os.environ["CONFIGURE_COMMAND"] + if os.environ["BUILD_COMMAND"]: + sections.append("build") + receipt["build_command"] = os.environ["BUILD_COMMAND"] + if os.environ["TEST_COMMAND"]: + log = pathlib.Path(os.environ["TEST_LOG"]) + if log.is_file(): + text = log.read_text(encoding="utf-8") + total = re.search(r"tests failed out of (\d+)", text) + sections.append("test") + receipt["test_command"] = os.environ["TEST_COMMAND"] + receipt["test_count"] = ( + int(total.group(1)) if "100% tests passed" in text and total else 0 + ) + receipt["test_log_sha256"] = hashlib.sha256(log.read_bytes()).hexdigest() + + receipt["sections"] = sorted(sections) + encoded = json.dumps(receipt, sort_keys=True, separators=(",", ":")) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"evidence={encoded}\n") + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: + summary.write(f"\nRuntime evidence: `{encoded}`\n") + PY - name: Summary env: diff --git a/.github/workflows/runtime-fixtures-languages.yml b/.github/workflows/runtime-fixtures-languages.yml index ce587e2..38f2e49 100644 --- a/.github/workflows/runtime-fixtures-languages.yml +++ b/.github/workflows/runtime-fixtures-languages.yml @@ -406,13 +406,131 @@ jobs: install_command: "cargo install cargo-mutants --locked" mutation_command: "cargo mutants --no-shuffle --test-tool=cargo -j 2" + fixture-kotlin-android-ci: + name: fixture / kotlin-android-ci + permissions: + contents: read + uses: ./.github/workflows/kotlin-android-ci.yml + with: + runner: ubuntu-latest + working_directory: tests/fixtures/android + + observe-kotlin-android-ci: + name: observe / kotlin-android-ci + needs: fixture-kotlin-android-ci + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up isolated Python contract runtime + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + update-environment: false + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - name: Provision validator + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: | + "$PYTHON_PATH" -I -B -m venv --copies .venv + uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt + - name: Reject missing, skipped, or partial Android evidence + env: + CALLER_RESULT: ${{ needs.fixture-kotlin-android-ci.result }} + SDK_RUNTIME_EVIDENCE: ${{ needs.fixture-kotlin-android-ci.outputs.evidence }} + run: | + test "$CALLER_RESULT" = success + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch check_sdk_runtime_fixtures.py -- --receipt android + + # Proves the committed Android closure still reproduces from a clean tree. The + # generator resolves the exact default `./gradlew build` twice -- once writing + # locks and verification metadata, once replaying them strictly from a fresh + # Gradle home -- and compares the result against what is committed. Only facts + # any conforming toolchain reproduces are compared; host JDK and SDK identity + # belong to the per-run receipt, not to a file in git. + generate-android-provenance: + name: reproduce / android provenance + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up JDK + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: '21' + distribution: temurin + - name: Set up Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + with: + packages: 'platforms;android-37.0 build-tools;36.0.0' + - name: Set up isolated Python contract runtime + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + update-environment: false + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - name: Provision validator + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: | + "$PYTHON_PATH" -I -B -m venv --copies .venv + uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt + - name: Regenerate the exact-build closure + run: | + .venv/bin/python -I -B scripts/check_python_execution_contract.py \ + --launch generate_android_fixture_provenance.py -- + - name: Publish the generated closure + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: android-fixture-closure + path: | + tests/fixtures/android/gradle/verification-metadata.xml + tests/fixtures/android/gradle/provenance-manifest.json + tests/fixtures/android/**/*.lockfile + if-no-files-found: error + - name: Reject drift from the committed closure + run: | + set -euo pipefail + if ! git --no-pager diff --exit-code -- tests/fixtures/android; then + echo "::error::the committed Android closure no longer reproduces" >&2 + exit 1 + fi + echo "Android exact-build closure reproduced byte for byte." \ + >> "$GITHUB_STEP_SUMMARY" + # One place to read the outcome, and the digests to paste into the ledger when + # `dart-flutter-ci.yml` and `qt-ci.yml` have no lane here on purpose. Their + # vendored setup actions name nested actions by tag, and this repository + # requires every action to be pinned to a full-length commit SHA, so the job is + # refused during `Set up job` before a step runs. A lane that cannot start is + # not evidence, and leaving it wired keeps this summary red for as long as the + # block lasts, which is how an estate stops reporting the next real + # regression. `catalog/runtime-coverage.yml` records both as `blocked` with the + # barrier and the handoff, and `check_sdk_runtime_fixtures.py` fails if the two + # ever disagree. See https://github.com/NDDev-it-com/ci-workflows/issues/150 evidence: name: evidence summary (languages) needs: - fixture-mutation-testing - fixture-r-ci - fixture-benchmark-compare + - fixture-kotlin-android-ci + - observe-kotlin-android-ci - fixture-go-ci - fixture-python-ci - fixture-node-ci @@ -466,7 +584,9 @@ jobs: RESULTS: ${{ toJSON(needs) }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} PROVES: >- - {"fixture-go-ci":"go-ci.yml","fixture-python-ci":"python-ci.yml","fixture-node-ci":"node-ci.yml","fixture-rust-ci":"rust-ci.yml","fixture-cpp-ci":"cpp-ci.yml","fixture-terraform-ci":"terraform-ci.yml","fixture-sql-ci":"sql-ci.yml","fixture-docs-quality":"docs-quality.yml","fixture-hadolint":"hadolint-ci.yml","fixture-web-ci":"web-ci.yml","fixture-container-ci":"container-ci.yml","fixture-cross-platform-smoke":"cross-platform-smoke.yml","fixture-java-ci":"java-ci.yml","fixture-dotnet-ci":"dotnet-ci.yml","fixture-swift-ci":"swift-ci.yml","fixture-mutation-testing":"mutation-testing.yml","fixture-r-ci":"r-ci.yml","fixture-benchmark-compare":"benchmark-compare.yml","fixture-go-ci-os":"go-ci.yml","fixture-python-ci-os":"python-ci.yml","fixture-rust-ci-os":"rust-ci.yml","fixture-dotnet-ci-os":"dotnet-ci.yml","fixture-java-ci-os":"java-ci.yml","fixture-node-ci-os":"node-ci.yml","fixture-terraform-ci-os":"terraform-ci.yml","fixture-sql-ci-os":"sql-ci.yml","fixture-web-ci-os":"web-ci.yml"} + {"fixture-go-ci":"go-ci.yml","fixture-python-ci":"python-ci.yml","fixture-node-ci":"node-ci.yml","fixture-rust-ci":"rust-ci.yml","fixture-cpp-ci":"cpp-ci.yml","fixture-terraform-ci":"terraform-ci.yml","fixture-sql-ci":"sql-ci.yml","fixture-docs-quality":"docs-quality.yml","fixture-hadolint":"hadolint-ci.yml","fixture-web-ci":"web-ci.yml","fixture-container-ci":"container-ci.yml","fixture-cross-platform-smoke":"cross-platform-smoke.yml","fixture-java-ci":"java-ci.yml","fixture-dotnet-ci":"dotnet-ci.yml","fixture-swift-ci":"swift-ci.yml","fixture-mutation-testing":"mutation-testing.yml","fixture-r-ci":"r-ci.yml","fixture-benchmark-compare":"benchmark-compare.yml","fixture-kotlin-android-ci":"kotlin-android-ci.yml","fixture-go-ci-os":"go-ci.yml","fixture-python-ci-os":"python-ci.yml","fixture-rust-ci-os":"rust-ci.yml","fixture-dotnet-ci-os":"dotnet-ci.yml","fixture-java-ci-os":"java-ci.yml","fixture-node-ci-os":"node-ci.yml","fixture-terraform-ci-os":"terraform-ci.yml","fixture-sql-ci-os":"sql-ci.yml","fixture-web-ci-os":"web-ci.yml"} + GUARDS: >- + {"fixture-kotlin-android-ci":["observe-kotlin-android-ci"]} run: | set -euo pipefail .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch render_runtime_evidence.py -- diff --git a/.github/workflows/runtime-fixtures.yml b/.github/workflows/runtime-fixtures.yml index 1e061ac..835da40 100644 --- a/.github/workflows/runtime-fixtures.yml +++ b/.github/workflows/runtime-fixtures.yml @@ -135,6 +135,14 @@ jobs: uses: ./.github/workflows/osv-scan.yml with: runner: ubuntu-latest + # tests/fixtures/android vendors the Android Gradle Plugin's transitive + # closure so the fixture can build under LockMode.STRICT. It is a fixture + # input, not something this library ships or whose versions it picks, and + # scanning it reports 159 findings nobody here can act on. osv-scanner has + # no config-file path scoping -- `PackageOverrides.path` is rejected as an + # unknown key by 2.4.0 -- so the exclusion is a scan argument. Grype and + # Trivy express the same exclusion in .grype.yaml and trivy.yaml. + scan_args: '--recursive --experimental-exclude tests/fixtures/android .' # security-blocking — Semgrep SAST, exercising the config input and the # non-SARIF gate path. diff --git a/.grype.yaml b/.grype.yaml new file mode 100644 index 0000000..2cbbcad --- /dev/null +++ b/.grype.yaml @@ -0,0 +1,17 @@ +# Grype configuration for this repository's own scans. +# +# `tests/fixtures/android` vendors the Android Gradle Plugin's transitive +# dependency closure -- 187 locked packages, 319 pinned in verification +# metadata. It exists only so the Android fixture can build under +# `LockMode.STRICT`. This repository does not ship it, import it, or choose its +# versions, and scanning it reported 159 known vulnerabilities that nobody here +# can act on, which turned three estate lanes permanently red and would have +# buried any finding that did matter. +# +# Two patterns because the exclude is matched relative to the scan root, and +# this closure is reached from both: the estate lane scans `tests/fixtures`, +# while a repository-root scan sees the longer path. Everything else under +# `tests/fixtures` is still scanned. +exclude: + - "./android/**" + - "./tests/fixtures/android/**" diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c0a84..0a94aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,94 @@ plane's `promotion_record.py`) are correctly out of scope. `.claude/CLAUDE.md` is corrected and registered; `.gds/repository.yaml` and `catalog/scorecard-evidence.yml` carry reasoned exemptions. +- Give `dart-flutter-ci.yml`, `kotlin-android-ci.yml` and `qt-ci.yml` a runtime + receipt that describes the run instead of one repository's fixture, and prove + the Android lane end to end. The evidence surface these three gained was + unreleased — `main` had no `outputs:` on any of them — so this is the last + point at which its shape could be chosen rather than migrated. + + **The receipt named the wrong workflow.** Each step hashed + `${github.workspace}/.github/workflows/.yml`, but inside a called + reusable every `github.*` value belongs to the *caller*, so that path is the + caller's checkout: an external consumer either has no such file and the step + dies, or has a same-named file of their own whose bytes are then published as + the provenance of a workflow they never edited. The receipt now records + `job.workflow_repository` / `job.workflow_sha` / `job.workflow_file_path`, + which the runner binds to the callee. A commit SHA is already the + cryptographic identity of the content at a path, so the triple pins the bytes + without a second checkout, works for a private callee, and cannot be forged by + the caller. Proven rather than read off documentation: a probe reusable pinned + at an absolute commit and called from a different commit reported the pinned + SHA in `job.workflow_sha` and the caller's head in `github.sha`. + + **The reusables demanded one repository's fixture.** Qt documents three + commands as "Empty to skip" and then read the test log unconditionally, so a + caller taking the documented option got `FileNotFoundError` rather than a + receipt. Android required a Gradle wrapper, `ANDROID_HOME`, a + `verification-metadata.xml`, a module named `app` and an APK — in a workflow + whose header promises it "works for pure-JVM Kotlin and Android", which + produces no APK. Each receipt is now a discriminated record: `kind` selects + the vocabulary, `sections` says what the run produced, and a skipped lane is + absent rather than fatal. Android reports `untrusted_roots`, keeping "no SDK + here" distinct from "an SDK I will not vouch for". Fixture-specific + assertions live in the observers and `check_sdk_runtime_fixtures.py`. + + **One grammar for every digest.** `_is_sha` reached exactly two fields; + `pubspec_lock_sha256`, `apk_sha256`, `lock_sha256` and + `verification_metadata_sha256` were accepted on truthiness, so + `["nope"]` was a valid list of APK digests. Scalars are 64 lowercase hex, + lists are non-empty and repeat nothing, mappings are keyed by relative POSIX + paths in lexical order — which rejects an absolute path, a `..` traversal and + a Windows separator. + +- Stop the SDK contract being a function of the machine that runs it. The valid + case of the root-trust self-test was built from a real temporary directory, so + under `umask 002` it arrived as `0o775` and the rule correctly rejected it: + a blocking validator that was green on runners and red for any contributor + whose umask is 002. The same construction inverted the negative case, since + under root the "unowned" probe could not make anything unowned. + `ownership_problem` is now a pure function of `(uid, mode)` exercised on + stated pairs, and it names its trust model: `exclusive-filesystem` is the + strict rule unchanged, while `ephemeral-single-tenant-runner` stops reading + mode bits because GitHub's hosted tool cache is mode 0777 and a VM destroyed + with the job has no other principal for those bits to name. Ownership is + enforced under both. An unrecognised model fails closed. + +- Prove `kotlin-android-ci.yml` on a real runner, and commit the closure that + makes it possible: 557 verified artifacts, two Gradle 9.5 lockfiles and a + provenance receipt for the exact default `./gradlew build` task graph, + generated by a hosted lane rather than a laptop and re-derived and diffed on + every estate run. The manifest records only what any conforming toolchain + reproduces — host JDK and SDK identity was being written into a committed file, + which would have reported drift on every runner-image JDK bump — and the task + graph is stored sorted, because Gradle schedules independent tasks + concurrently and the execution order is not a property of the build. + +- Report `dart-flutter-ci.yml` and `qt-ci.yml` as `blocked` rather than + `unverified`, because running them established exactly why they fail. + `subosito/flutter-action` and `jurplel/install-qt-action` are pinned by SHA + here, but their own definitions name nested actions by tag + (`actions/cache@v5`, `actions/setup-python@v6`, + `jurplel/install-qt-action/action@v4`), and GitHub resolves the whole nested + graph at job setup — so both are rejected before a step runs, under the very + "pin every action to a full commit SHA" control this library recommends to + consumers. No input can reach a setup-time decision. `runtime_debt.barrier` + gains `dependency-policy-conflict` for this class, and issue #150 carries the + fix: self-provision both toolchains from pins we already own. + +- Resolve the Flutter pin against Google's published manifest instead of by eye. + `check_flutter_pin.py` requires channel, version, Dart version, framework + revision and archive digest to identify exactly one release row. An external + audit reading the same pin by eye concluded it was unresolvable and asked for + it to be re-pinned; it resolves, and acting on that would have broken correct + configuration. Advisory tier, since whether a third party still publishes a row + is a calendar fact about someone else. + +- Close three defects that stopped the SDK evidence estate running at all: the + three observer jobs never installed `uv` before `uv pip install`; the hermetic + execution boundary stripped `SDK_RUNTIME_EVIDENCE`, so the observer could not + read the receipt it exists to check; and `^> Task (:[^ ]+)` matched across + newlines, so task names arrived as `":app:build\n>"`. - Add one hermetic Python execution boundary for every repository validator and generator. A machine-readable policy now pins the interpreter and PyYAML, diff --git a/catalog/capabilities.yml b/catalog/capabilities.yml index bf376ae..d41b295 100644 --- a/catalog/capabilities.yml +++ b/catalog/capabilities.yml @@ -1152,6 +1152,7 @@ capabilities: - "contents: read" required_settings: [] risks: + - "Runtime evidence pins Flutter and validates the resolved Dart/Flutter identity; mutable channel selectors are not reproducible proof" - "Private-repo runner minutes are metered beyond the included free allotment" - "iOS/macOS builds require macOS runners billed at a 10x minute multiplier" deprecations: null @@ -1236,6 +1237,7 @@ capabilities: - "contents: read" required_settings: [] risks: + - "Runtime evidence needs an exact qt_version plus pinned aqtinstall/py7zr identities; a wildcard selector is not reproducible proof" - "aqtinstall downloads Qt at runtime; pin qt_version for reproducible builds" - "Private-repo runner minutes are metered beyond the included free allotment" deprecations: null @@ -1257,6 +1259,7 @@ capabilities: - "contents: read" required_settings: [] risks: + - "Runtime evidence records the JDK, Gradle and Android identities that were actually resolved; sections absent from a receipt were never proven, not silently assumed" - "ktlint/detekt run as project-defined Gradle tasks supplied via lint_command" - "Private-repo runner minutes are metered beyond the included free allotment" deprecations: null diff --git a/catalog/python-execution.yml b/catalog/python-execution.yml index f9d20f7..02610c1 100644 --- a/catalog/python-execution.yml +++ b/catalog/python-execution.yml @@ -2,7 +2,7 @@ "schema_version": 1, "python": { "major_minor": "3.13", - "subject_count": 45, + "subject_count": 51, "launcher": "scripts/check_python_execution_contract.py", "launcher_prefix": [".venv/bin/python", "-I", "-B", "scripts/check_python_execution_contract.py", "--launch"], "syntax_gate_prefix": [".venv/bin/python", "-I", "-B", "scripts/check_python_syntax.py"], @@ -96,6 +96,7 @@ "requirements-ci.txt", "scripts/__init__.py", "scripts/validate_all.py" ], "surface_environment": { + "check_sdk_runtime_fixtures.py": ["SDK_RUNTIME_EVIDENCE"], "negative_gate_probe.py": ["GH_TOKEN", "GITHUB_TOKEN"], "render_runtime_evidence.py": ["GUARDS", "PROVES", "RESULTS", "RUN_URL"], "verify_scorecard_runtime.py": ["GH_HOST", "GH_TOKEN"] @@ -111,10 +112,11 @@ "shell-fixture": {"api": "subprocess", "executable": "path-search", "argv": "sequence", "cwd": "explicit-or-preserve", "environment": "replace-clean"} }, "surface_groups": { - "library": ["_json_schema.py", "_runners.py", "_workflow_yaml.py"], + "library": ["_gradle_lockfile.py", "_json_schema.py", "_runners.py", "_sdk_environment.py", "_workflow_yaml.py"], "read_only_validator": [ "_strict_yaml.py", "check_actionlint_contract.py", "check_benchmark_contract.py", "check_docs_links.py", "check_documented_commands.py", + "check_flutter_pin.py", "check_examples.py", "check_gate_contract.py", "check_harden_runner_contract.py", "check_merge_group.py", "check_monorepo_routing.py", @@ -126,7 +128,7 @@ "check_release_promotion_gate.py", "check_release_supply_chain.py", "check_rulesets.py", "check_runner_routing.py", "check_runtime_requirements.py", "check_scorecard_evidence_contract.py", - "check_secret_scan_contract.py", + "check_secret_scan_contract.py", "check_sdk_runtime_fixtures.py", "check_side_effect_fixture_contract.py", "check_skills.py", "check_tool_pinning.py", "check_tool_registry.py", "check_workflow_contracts.py", "compile_evidence_plan.py", @@ -135,17 +137,20 @@ "validate_runtime_coverage.py", "verify_scorecard_runtime.py" ], "read_only_generator_check": ["generate_docs.py"], - "mutating_generator_import_help_only": ["sync_skills.py"], + "mutating_generator_import_help_only": ["generate_android_fixture_provenance.py", "generate_sdk_runtime_manifest.py", "sync_skills.py"], "aggregate": ["validate_all.py"], "fixture_harness": ["negative_gate_probe.py"] }, "sibling_imports": { "__init__.py": [], + "_gradle_lockfile.py": [], "_json_schema.py": [], + "_sdk_environment.py": [], "_workflow_yaml.py": ["_strict_yaml"], "check_actionlint_contract.py": ["_workflow_yaml", "check_python_execution_contract"], "check_benchmark_contract.py": ["_workflow_yaml"], "check_documented_commands.py": ["_workflow_yaml"], + "check_flutter_pin.py": ["_strict_yaml"], "check_examples.py": ["_runners", "_workflow_yaml"], "check_gate_contract.py": ["_strict_yaml", "_workflow_yaml", "check_python_execution_contract"], "check_harden_runner_contract.py": ["_workflow_yaml"], @@ -164,6 +169,7 @@ "check_runtime_requirements.py": ["_strict_yaml", "_workflow_yaml"], "check_scorecard_evidence_contract.py": ["_strict_yaml", "_workflow_yaml", "check_harden_runner_contract", "check_python_execution_contract"], "check_secret_scan_contract.py": ["_strict_yaml", "_workflow_yaml", "check_python_execution_contract"], + "check_sdk_runtime_fixtures.py": ["_gradle_lockfile", "_sdk_environment", "_strict_yaml", "_workflow_yaml", "generate_sdk_runtime_manifest"], "check_side_effect_fixture_contract.py": ["_strict_yaml", "_workflow_yaml", "check_python_execution_contract"], "check_skills.py": ["_strict_yaml"], "check_tool_pinning.py": ["_workflow_yaml"], @@ -171,11 +177,14 @@ "check_workflow_contracts.py": ["_runners", "_workflow_yaml"], "compile_evidence_plan.py": ["_strict_yaml", "_workflow_yaml"], "generate_docs.py": ["_strict_yaml", "_workflow_yaml", "resolve_profile"], + "generate_android_fixture_provenance.py": ["_gradle_lockfile", "_sdk_environment", "_strict_yaml", "check_python_execution_contract"], + "generate_sdk_runtime_manifest.py": ["_strict_yaml"], "negative_gate_probe.py": ["_strict_yaml", "check_python_execution_contract"], "resolve_profile.py": ["_strict_yaml"], "validate_all.py": [ "_strict_yaml", "check_actionlint_contract", "check_benchmark_contract", "check_docs_links", "check_documented_commands", "check_examples", + "check_flutter_pin", "check_gate_contract", "check_harden_runner_contract", "check_merge_group", "check_monorepo_routing", "check_permissions", "check_pinned_actions", @@ -186,7 +195,7 @@ "check_release_promotion_gate", "check_release_supply_chain", "check_rulesets", "check_runner_routing", "check_runtime_requirements", "check_scorecard_evidence_contract", - "check_secret_scan_contract", + "check_secret_scan_contract", "check_sdk_runtime_fixtures", "check_side_effect_fixture_contract", "check_skills", "check_tool_pinning", "check_tool_registry", "check_workflow_contracts", "compile_evidence_plan", "generate_docs", "render_runtime_evidence", "resolve_profile", @@ -230,6 +239,10 @@ "check_scorecard_evidence_contract.py": {"_run_guard": {"count": 1, "profile": "isolated-python-fixture"}}, "check_secret_scan_contract.py": {"_run": {"count": 1, "profile": "isolated-python-fixture"}}, "check_side_effect_fixture_contract.py": {"_run_with_fake_gh": {"count": 1, "profile": "shell-fixture"}}, + "generate_android_fixture_provenance.py": { + "_java_properties": {"count": 1, "profile": "external-tool-fixture"}, + "_run": {"count": 1, "profile": "external-tool-fixture"} + }, "negative_gate_probe.py": {"run_in": {"count": 1, "profile": "shell-fixture"}}, "validate_all.py": {"changed_paths": {"count": 2, "profile": "external-tool"}}, "verify_scorecard_runtime.py": { diff --git a/catalog/runtime-coverage.yml b/catalog/runtime-coverage.yml index 59b19c2..9451c61 100644 --- a/catalog/runtime-coverage.yml +++ b/catalog/runtime-coverage.yml @@ -66,14 +66,14 @@ entries: waiver: null - workflow: .github/workflows/dart-flutter-ci.yml criticality: supporting - status: unverified - evidence: "Not fixtured. Needs the Flutter SDK, which is a multi-minute provisioning step for one lane; deferred to its own change rather than added to the estate here. No consumer run observed." + status: blocked + evidence: "Fixture and observer are wired, but the job is rejected during `Set up job`: subosito/flutter-action refers to nested actions by tag and this repository requires every action to be pinned to a full-length commit SHA. No step runs, so nothing is proven. Not a fixture gap: any consumer with the same control enabled cannot call this workflow either. Observed in https://github.com/NDDev-it-com/ci-workflows/actions/runs/31782942981" last_run: null runtime_debt: risk: medium - barrier: heavy-toolchain - required_capability: Minimal Flutter project and hosted-runner SDK provisioning fixture. - handoff: https://github.com/NDDev-it-com/ci-workflows/issues/129 + barrier: dependency-policy-conflict + required_capability: Self-provisioned toolchain install that pins every action it uses, replacing the vendored setup action. + handoff: https://github.com/NDDev-it-com/ci-workflows/issues/150 waiver: null - workflow: .github/workflows/docs-ci.yml criticality: supporting @@ -146,15 +146,13 @@ entries: proven_digest: 47c06ab665e7c6e8a1f529c044c386d0e822ad29b340978735602da55e2a0527 waiver: null - workflow: .github/workflows/kotlin-android-ci.yml + proven_os: + - linux criticality: supporting - status: unverified - evidence: "Not fixtured. Needs the Android SDK and a Gradle project; deferred to its own change. No consumer run observed." - last_run: null - runtime_debt: - risk: medium - barrier: heavy-toolchain - required_capability: Minimal Gradle Android project using the hosted Android SDK. - handoff: https://github.com/NDDev-it-com/ci-workflows/issues/129 + status: runtime-proven + evidence: "Fixture caller ran the default `./gradlew build` against tests/fixtures/android on ubuntu-latest and the observer accepted the receipt: nine sections (android_sdk, artifacts, build, dependency_verification, gradle, jdk, locks, tests, wrapper), an empty untrusted_roots, one test, two APKs, two Gradle 9.5 lockfiles, and the exact default task graph including :app:assembleDebug, :app:testDebugUnitTest, :app:lintDebug and :app:build. Provenance names the callee: callee_path is kotlin-android-ci.yml while the caller is runtime-fixtures-languages.yml. The committed dependency closure of 557 verified artifacts is regenerated from a clean tree and diffed in the same run. Linux only \u2014 the closure was generated on ubuntu-latest and macOS/Windows would each need their own." + last_run: https://github.com/NDDev-it-com/ci-workflows/actions/runs/31783511309 + proven_digest: 25229a37bc855db163f6ba58b1b7a6b52187c981a2e34694e6eb194ce27f94a6 waiver: null - workflow: .github/workflows/monorepo-changed-paths.yml criticality: required-gate @@ -254,14 +252,14 @@ entries: waiver: null - workflow: .github/workflows/qt-ci.yml criticality: supporting - status: unverified - evidence: "Not fixtured. Needs a Qt installation, the heaviest provisioning step in the library; deferred to its own change. No consumer run observed." + status: blocked + evidence: "Fixture and observer are wired, but the job is rejected during `Set up job`: jurplel/install-qt-action refers to nested actions by tag and this repository requires every action to be pinned to a full-length commit SHA. No step runs, so nothing is proven. Not a fixture gap: any consumer with the same control enabled cannot call this workflow either. Observed in https://github.com/NDDev-it-com/ci-workflows/actions/runs/31782942981" last_run: null runtime_debt: risk: medium - barrier: heavy-toolchain - required_capability: Minimal CMake Qt project and hosted-runner Qt provisioning. - handoff: https://github.com/NDDev-it-com/ci-workflows/issues/129 + barrier: dependency-policy-conflict + required_capability: Self-provisioned toolchain install that pins every action it uses, replacing the vendored setup action. + handoff: https://github.com/NDDev-it-com/ci-workflows/issues/150 waiver: null - workflow: .github/workflows/r-ci.yml criticality: supporting diff --git a/catalog/tools.yml b/catalog/tools.yml index bc67f2a..882a037 100644 --- a/catalog/tools.yml +++ b/catalog/tools.yml @@ -222,6 +222,7 @@ tools: - .github/workflows/public-scorecard.yml - .github/workflows/release-supply-chain-free.yml - .github/workflows/release-supply-chain.yml + - .github/workflows/runtime-fixtures-languages.yml - .github/workflows/secret-scan.yml last_verified: "2026-08-04" @@ -274,6 +275,7 @@ tools: pin: "android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699" used_by: - .github/workflows/kotlin-android-ci.yml + - .github/workflows/runtime-fixtures-languages.yml last_verified: "2026-07-08" - id: setup-swift @@ -471,6 +473,7 @@ tools: - .github/workflows/private-static.yml - .github/workflows/python-ci.yml - .github/workflows/release.yml + - .github/workflows/runtime-fixtures-languages.yml - .github/workflows/semgrep-ci.yml - .github/workflows/sql-ci.yml - .github/workflows/zizmor-no-sarif.yml @@ -535,6 +538,7 @@ tools: used_by: - .github/workflows/java-ci.yml - .github/workflows/kotlin-android-ci.yml + - .github/workflows/runtime-fixtures-languages.yml last_verified: "2026-07-25" - id: setup-terraform diff --git a/docs/08-governance-rulesets.md b/docs/08-governance-rulesets.md index 380bcb9..9ae73d7 100644 --- a/docs/08-governance-rulesets.md +++ b/docs/08-governance-rulesets.md @@ -200,6 +200,22 @@ Push rulesets evaluate **before** the ref updates and can block: These run on the push itself, catching mistakes earlier than a PR check. +## Long-lived refs outside the release flow + +Two kinds of branch here are kept rather than cleaned up, and neither is covered +by the tag rules above, so the convention is written down instead: + +- `checkpoint/**` — a work-in-progress snapshot attached to an open issue. It is + not merge-ready by construction and its pull request says so. +- `archive/**` — a frozen pre-rewrite state, kept so a rewritten branch's history + stays reachable. + +Two archive refs currently resolve to the same object, `0215cf26`: +`archive/2026-08-13-ci-workflows-sdk-pre-python-split` is **canonical** and +`archive/2026-08-13-ci-workflows-129-pre-python-split` is a documented alias of +it. Neither is deleted. Cite the canonical name; expect to meet the alias in +older issue comments. + ## Migrating from classic branch protection 1. Read the existing protection (`GET /repos/{owner}/{repo}/branches/{branch}/protection`). diff --git a/docs/15-language-and-quality-packs.md b/docs/15-language-and-quality-packs.md index 6053a5f..db976c5 100644 --- a/docs/15-language-and-quality-packs.md +++ b/docs/15-language-and-quality-packs.md @@ -30,6 +30,35 @@ Dual-tier, caller-command-driven with sensible defaults. | HTML/CSS/web | `web-ci.yml` | [web](../examples/languages/web.yml) | | SQL | `sql-ci.yml` | [sql](../examples/languages/sql.yml) | +The Dart/Flutter, Kotlin/Android, and Qt callers above deliberately retain each +reusable workflow's default resolve/build/test commands, so the fixture estate +exercises the defaults a consumer inherits rather than a bespoke invocation. Pin +an exact Flutter or Qt release: a mutable channel selector resolves to different +bytes on different days and cannot be evidence of anything. + +Each of the three emits a **runtime receipt** as a `workflow_call` output. The +receipt is a discriminated record — `kind` names the pack, `sections` lists what +that run actually produced — and it is deliberately generic. A caller who takes +a documented "Empty to skip" option simply gets a receipt without that section; +the reusable never requires a Gradle wrapper, an Android SDK, an APK, a module +named `app`, or dependency-verification metadata, because none of those are +things a reusable workflow may demand of an arbitrary project. Android also +reports `untrusted_roots`, which keeps "no SDK on this runner" distinct from "an +SDK I will not vouch for". + +Provenance in the receipt names the **callee**, via +`job.workflow_repository` / `job.workflow_sha` / `job.workflow_file_path`. Inside +a called reusable every `github.*` value describes the *caller*, so anything +derived from `github.workspace` or `github.workflow_ref` would describe the +consumer's tree instead of the workflow that ran. + +Assertions specific to this repository's fixtures — the exact task graph, the +required SDK platform and build-tools, the APK, the dependency locks, the +verification metadata, the CTest count — live in the observer jobs of +`runtime-fixtures-languages.yml` and in `check_sdk_runtime_fixtures.py`, never in +the reusable. Observers reject a missing, skipped, or partial caller result. +Provisioning duration is telemetry, not a correctness threshold. + These join the existing Python, Node, Go, Rust, Java, .NET, container, and Terraform packs. Swift defaults to a macOS runner (10x minute multiplier); its SwiftLint step runs on macOS only. diff --git a/docs/generated/runtime-coverage.md b/docs/generated/runtime-coverage.md index dda56a6..f0dc7eb 100644 --- a/docs/generated/runtime-coverage.md +++ b/docs/generated/runtime-coverage.md @@ -1,7 +1,7 @@ # Generated runtime-evidence closure ledger -Inventory: 47 reusable workflows (blocked 3, partial-runtime 1, runtime-proven 38, unverified 5). +Inventory: 47 reusable workflows (blocked 5, partial-runtime 1, runtime-proven 39, unverified 2). Only `runtime-proven` means an observed successful `workflow_call` run for the current workflow digest. Every other row is explicit debt; static @@ -17,7 +17,7 @@ validation and skipped jobs are not runtime evidence. | `.github/workflows/coverage-gate.yml` | `required-gate` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/cpp-ci.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/cross-platform-smoke.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | -| `.github/workflows/dart-flutter-ci.yml` | `supporting` | `unverified` | `medium` | `heavy-toolchain` | Minimal Flutter project and hosted-runner SDK provisioning fixture. | [issue](https://github.com/NDDev-it-com/ci-workflows/issues/129) | +| `.github/workflows/dart-flutter-ci.yml` | `supporting` | `blocked` | `medium` | `dependency-policy-conflict` | Self-provisioned toolchain install that pins every action it uses, replacing the vendored setup action. | [issue](https://github.com/NDDev-it-com/ci-workflows/issues/150) | | `.github/workflows/docs-ci.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/docs-quality.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/dotnet-ci.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | @@ -28,7 +28,7 @@ validation and skipped jobs are not runtime evidence. | `.github/workflows/hadolint-ci.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/iac-scan.yml` | `security-blocking` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/java-ci.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | -| `.github/workflows/kotlin-android-ci.yml` | `supporting` | `unverified` | `medium` | `heavy-toolchain` | Minimal Gradle Android project using the hosted Android SDK. | [issue](https://github.com/NDDev-it-com/ci-workflows/issues/129) | +| `.github/workflows/kotlin-android-ci.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/monorepo-changed-paths.yml` | `required-gate` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/mutation-testing.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/node-ci.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | @@ -41,7 +41,7 @@ validation and skipped jobs are not runtime evidence. | `.github/workflows/public-scorecard-json.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/public-scorecard.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/python-ci.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | -| `.github/workflows/qt-ci.yml` | `supporting` | `unverified` | `medium` | `heavy-toolchain` | Minimal CMake Qt project and hosted-runner Qt provisioning. | [issue](https://github.com/NDDev-it-com/ci-workflows/issues/129) | +| `.github/workflows/qt-ci.yml` | `supporting` | `blocked` | `medium` | `dependency-policy-conflict` | Self-provisioned toolchain install that pins every action it uses, replacing the vendored setup action. | [issue](https://github.com/NDDev-it-com/ci-workflows/issues/150) | | `.github/workflows/r-ci.yml` | `supporting` | `runtime-proven` | `—` | `—` | — | — | | `.github/workflows/release-promotion-gate.yml` | `release` | `blocked` | `critical` | `external-authority` | GitHub-verified maintainer signing identity, private control-plane read authority, exact promotion record, numeric tag and protected release environment. | [issue](https://github.com/NDDev-it-com/ci-workflows/issues/134) | | `.github/workflows/release-supply-chain-free.yml` | `release` | `blocked` | `critical` | `destructive-release` | Dedicated disposable repository where publishing and deleting a unique release/tag cannot affect consumers or protected project history. | [issue](https://github.com/NDDev-it-com/ci-workflows/issues/135) | diff --git a/docs/generated/workflow-routing.md b/docs/generated/workflow-routing.md index 129798e..d27f41d 100644 --- a/docs/generated/workflow-routing.md +++ b/docs/generated/workflow-routing.md @@ -26,7 +26,7 @@ of a value is not proof that a workflow runs on that platform. | `.github/workflows/hadolint-ci.yml` | `linux-shell` | linux | shell only | — | | `.github/workflows/iac-scan.yml` | `linux-shell` | linux | shell only | — | | `.github/workflows/java-ci.yml` | `portable-shell` | linux, macos, windows | shell only | linux, macos, windows | -| `.github/workflows/kotlin-android-ci.yml` | `linux-shell` | linux | shell only | — | +| `.github/workflows/kotlin-android-ci.yml` | `linux-shell` | linux | shell only | linux | | `.github/workflows/monorepo-changed-paths.yml` | `linux-shell` | linux | shell only | — | | `.github/workflows/mutation-testing.yml` | `linux-shell` | linux | shell only | — | | `.github/workflows/node-ci.yml` | `portable-shell` | linux, macos, windows | shell only | linux, macos, windows | diff --git a/examples/languages/dart-flutter.yml b/examples/languages/dart-flutter.yml index 8d57bc2..5dc50cb 100644 --- a/examples/languages/dart-flutter.yml +++ b/examples/languages/dart-flutter.yml @@ -14,4 +14,5 @@ jobs: with: runner: ubuntu-latest flutter_channel: stable - test_command: 'flutter test --coverage' + # Pin an exact release for reproducible evidence; omit to follow stable. + flutter_version: '3.47.0' diff --git a/examples/languages/kotlin-android.yml b/examples/languages/kotlin-android.yml index 2366156..2d4b555 100644 --- a/examples/languages/kotlin-android.yml +++ b/examples/languages/kotlin-android.yml @@ -13,5 +13,5 @@ jobs: uses: NDDev-it-com/ci-workflows/.github/workflows/kotlin-android-ci.yml@ with: runner: ubuntu-latest - lint_command: './gradlew ktlintCheck detekt' - build_command: './gradlew build' + # The checked wrapper drives the default `./gradlew build` contract. + java_version: '21' diff --git a/examples/languages/qt.yml b/examples/languages/qt.yml index 567f238..58ae2d5 100644 --- a/examples/languages/qt.yml +++ b/examples/languages/qt.yml @@ -13,5 +13,5 @@ jobs: uses: NDDev-it-com/ci-workflows/.github/workflows/qt-ci.yml@ with: runner: ubuntu-latest - qt_version: '6.8.*' - qt_modules: 'qtcharts' + # Use an exact Qt release for reproducible provisioning. + qt_version: '6.8.4' diff --git a/scripts/_gradle_lockfile.py b/scripts/_gradle_lockfile.py new file mode 100644 index 0000000..aa436ab --- /dev/null +++ b/scripts/_gradle_lockfile.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Strict parser for Gradle 9.5 canonical single-project lockfiles.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +HEADER = ( + "# This is a Gradle generated file for dependency locking.", + "# Manual edits can break the build and are not advised.", + "# This file is expected to be part of source control.", +) + + +class GradleLockfileError(ValueError): + """The lockfile is not canonical Gradle 9.5 writer output.""" + + +@dataclass(frozen=True) +class GradleLockfile: + entries: tuple[str, ...] + resolved_configurations: tuple[str, ...] + + +def parse_gradle_95_lockfile(path: Path, *, source: str | None = None) -> GradleLockfile: + label = source or str(path) + raw = path.read_bytes() + if b"\r" in raw or not raw.endswith(b"\n"): + raise GradleLockfileError(f"{label}: lockfile must be UTF-8 LF with terminal LF") + try: + lines = raw.decode("utf-8").splitlines() + except UnicodeDecodeError as exc: + raise GradleLockfileError(f"{label}: lockfile is not UTF-8") from exc + if tuple(lines[:3]) != HEADER: + raise GradleLockfileError(f"{label}: noncanonical Gradle lockfile header") + rows = lines[3:] + if not rows or rows[-1].split("=", 1)[0] != "empty": + raise GradleLockfileError(f"{label}: exactly one terminal empty= aggregate is required") + if sum(row.startswith("empty=") for row in rows) != 1: + raise GradleLockfileError(f"{label}: empty= aggregate is missing, duplicate, or misplaced") + modules: list[str] = [] + configurations: set[str] = set() + for index, row in enumerate(rows): + if not row or row.startswith("#") or row.count("=") != 1 or row != row.strip(): + raise GradleLockfileError(f"{label}:{index + 4}: malformed record") + module, joined = row.split("=", 1) + values = joined.split(",") if joined else [] + if values != sorted(set(values)) or any(not value for value in values): + raise GradleLockfileError(f"{label}:{index + 4}: configurations are not unique lexical values") + configurations.update(values) + if module == "empty": + continue + if module.count(":") < 2 or not all(module.split(":")): + raise GradleLockfileError(f"{label}:{index + 4}: invalid dependency notation") + if not values: + raise GradleLockfileError(f"{label}:{index + 4}: dependency has no configurations") + modules.append(module) + if modules != sorted(set(modules)): + raise GradleLockfileError(f"{label}: dependency records are not unique lexical values") + return GradleLockfile(tuple(modules), tuple(sorted(configurations))) diff --git a/scripts/_sdk_environment.py b/scripts/_sdk_environment.py new file mode 100644 index 0000000..511855e --- /dev/null +++ b/scripts/_sdk_environment.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Typed, fail-closed SDK toolchain environment transition.""" +from __future__ import annotations + +import json +import os +import stat +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +SDK_NAMES = ("ANDROID_HOME", "ANDROID_SDK_ROOT", "JAVA_HOME") +EVIDENCE_NAME = "NDDEV_SDK_ENV_RECEIPT" + + +class SdkEnvironmentError(ValueError): + """Observed SDK roots do not satisfy the pinned fixture contract.""" + + +@dataclass(frozen=True) +class OwnershipFacts: + """The two `stat` fields the root trust rule is a function of.""" + + uid: int + mode: int + + +EXCLUSIVE_FILESYSTEM = "exclusive-filesystem" +EPHEMERAL_SINGLE_TENANT = "ephemeral-single-tenant-runner" +ROOT_TRUST_MODELS = (EXCLUSIVE_FILESYSTEM, EPHEMERAL_SINGLE_TENANT) + + +def ownership_problem( + facts: OwnershipFacts, *, trusted_uid: int, trust_model: str = EXCLUSIVE_FILESYSTEM, +) -> str | None: + """Return why a root is untrusted, or None when it is trusted. + + Kept pure — a function of two integers rather than of a live directory — + because the rule it encodes cannot otherwise be tested honestly. Building + the cases out of real temporary directories makes the outcome a function of + the ambient environment instead of the rule: under `umask 002` a freshly + created directory is `0o775`, so the *valid* case failed on developer + machines while passing on runners, and under `root` every file is uid 0, so + the *unowned* case could not be constructed at all and the negative test + inverted. Both are ambient state leaking into a blocking gate. + + Ownership is enforced under every trust model: only root and the current user + may own a toolchain root. What the trust model selects is whether the mode + bits carry information. + + `exclusive-filesystem`, the default, is the strict rule: any group or world + write bit disqualifies a root, because on a shared or long-lived host those + bits name other principals who can swap the toolchain between validation and + use. + + `ephemeral-single-tenant-runner` accepts them, and exists because running + this on `ubuntu-latest` is what revealed that `actions/setup-java` installs + the JDK into the hosted tool cache at mode 0777. The strict rule therefore + refuses every GitHub-hosted runner -- the one environment where the + committed closure *should* be produced, since it is where the fixture lane + runs. Refusing it does not make anything safer; it just moves generation to + a maintainer's laptop, which is less reproducible and no better guarded. On + a single-tenant VM destroyed with the job there is no other principal for + those bits to name. + + An unrecognised trust model is itself a problem, so a typo fails closed + rather than selecting the permissive branch. + """ + if trust_model not in ROOT_TRUST_MODELS: + return f"declares an unknown root trust model {trust_model!r}" + if facts.uid not in {0, trusted_uid}: + return f"has an unowned uid {facts.uid} (mode {facts.mode & 0o7777:04o})" + if trust_model == EXCLUSIVE_FILESYSTEM and facts.mode & (stat.S_IWGRP | stat.S_IWOTH): + return f"is group/world writable (mode {facts.mode & 0o7777:04o})" + return None + + +@dataclass(frozen=True) +class JvmIdentity: + requested_major: str + java_home: str + java_version: str + java_runtime_version: str + java_vendor: str + java_vm_name: str + launcher_version: str + launcher_runtime_version: str + launcher_vendor: str + launcher_vm_name: str + daemon_home: str + daemon_version: str + daemon_runtime_version: str + daemon_vendor: str + daemon_vm_name: str + + +def validate_jvm_identity(identity: JvmIdentity) -> None: + """Require requested, observed, launcher and daemon identities to coincide.""" + if not identity.requested_major.isdigit(): + raise SdkEnvironmentError("requested Java major is not numeric") + for label, value in ( + ("observed", identity.java_version), + ("runtime", identity.java_runtime_version), + ("launcher", identity.launcher_version), + ("launcher runtime", identity.launcher_runtime_version), + ("daemon", identity.daemon_version), + ("daemon runtime", identity.daemon_runtime_version), + ): + if not value.startswith(identity.requested_major + "."): + raise SdkEnvironmentError(f"{label} Java identity does not match requested major") + if not all((identity.java_vendor, identity.java_vm_name, identity.launcher_vendor, + identity.launcher_vm_name, identity.daemon_vendor, identity.daemon_vm_name)): + raise SdkEnvironmentError("JVM vendor/name identity is incomplete") + if ( + identity.launcher_version != identity.java_version + or identity.launcher_runtime_version != identity.java_runtime_version + or identity.launcher_vendor != identity.java_vendor + or identity.launcher_vm_name != identity.java_vm_name + or identity.daemon_version != identity.java_version + or identity.daemon_runtime_version != identity.java_runtime_version + or identity.daemon_vendor != identity.java_vendor + or identity.daemon_vm_name != identity.java_vm_name + or Path(identity.daemon_home) != Path(identity.java_home) + ): + raise SdkEnvironmentError("observed, launcher, and daemon JVM identities diverged") + + +def _canonical_root_path(path: Path) -> Path: + """Resolve only the one documented Darwin namespace alias.""" + if sys.platform == "darwin" and path.parts[:2] == ("/", "var"): + candidate = Path("/private").joinpath(*path.parts[1:]) + if not candidate.exists() or not os.path.samefile(path, candidate): + raise SdkEnvironmentError("Darwin /var alias identity is incoherent") + return candidate + return path + + +def _trusted_root( + path: Path, *, label: str, uid: int, trust_model: str = EXCLUSIVE_FILESYSTEM, +) -> Path: + if not path.is_absolute() or not path.is_dir(): + raise SdkEnvironmentError(f"{label} must be an absolute regular directory") + if path.is_symlink(): + raise SdkEnvironmentError(f"{label} must not be a symlink") + canonical = _canonical_root_path(path) + resolved = canonical.resolve(strict=True) + if resolved != canonical: + raise SdkEnvironmentError(f"{label} uses an untrusted ancestor alias") + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open("/", flags) + try: + for component in resolved.parts[1:]: + child = os.open(component, flags, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + info = os.fstat(descriptor) + current = resolved.stat() + if (info.st_dev, info.st_ino) != (current.st_dev, current.st_ino): + raise SdkEnvironmentError(f"{label} identity changed during validation") + finally: + os.close(descriptor) + problem = ownership_problem( + OwnershipFacts(info.st_uid, info.st_mode), trusted_uid=uid, trust_model=trust_model, + ) + if problem is not None: + raise SdkEnvironmentError(f"{label} {problem}") + return resolved + + +def derive_android_environment( + clean: Mapping[str, str], ambient: Mapping[str, str], *, + java_executable: Path, java_properties: Mapping[str, str], + sdkmanager_executable: Path, java_major: str, + compile_sdk: str, build_tools: str, uid: int | None = None, + trust_model: str = EXCLUSIVE_FILESYSTEM, +) -> dict[str, str]: + """Derive owned roots from verified executables; never inherit SDK text.""" + owner = os.getuid() if uid is None else uid + java_real = java_executable.resolve(strict=True) + java_home = _trusted_root( + Path(str(java_properties.get("java.home", ""))), label="JAVA_HOME", uid=owner, + trust_model=trust_model, + ) + if java_real != (java_home / "bin/java").resolve(strict=True): + raise SdkEnvironmentError("java executable and observed java.home diverge") + version = str(java_properties.get("java.version", "")) + runtime = str(java_properties.get("java.runtime.version", "")) + if not version.startswith(f"{java_major}.") or not runtime.startswith(f"{java_major}."): + raise SdkEnvironmentError( + f"observed Java {version!r}/{runtime!r} does not satisfy JDK {java_major}" + ) + + manager_real = sdkmanager_executable.resolve(strict=True) + candidates = [parent for parent in manager_real.parents if ( + (parent / "platforms").is_dir() and (parent / "build-tools").is_dir() + )] + if len(candidates) != 1: + raise SdkEnvironmentError("sdkmanager does not identify exactly one Android SDK root") + android = _trusted_root( + candidates[0], label="Android SDK root", uid=owner, trust_model=trust_model, + ) + platform_names = {f"android-{compile_sdk}", f"android-{compile_sdk}.0"} + if not any((android / "platforms" / name).is_dir() for name in platform_names): + raise SdkEnvironmentError(f"Android platform {compile_sdk} is missing") + if not (android / "build-tools" / build_tools).is_dir(): + raise SdkEnvironmentError(f"Android build-tools {build_tools} are missing") + + result = {str(key): str(value) for key, value in clean.items()} + stripped = sorted(name for name in SDK_NAMES if name in ambient) + result.update({ + "ANDROID_HOME": str(android), + "ANDROID_SDK_ROOT": str(android), + "JAVA_HOME": str(java_home), + EVIDENCE_NAME: json.dumps({ + "android_root": str(android), + "build_tools": build_tools, + "compile_sdk": compile_sdk, + "java_home": str(java_home), + "java_runtime_version": runtime, + "java_version": version, + "root_trust_model": trust_model, + "stripped_ambient": stripped, + }, sort_keys=True, separators=(",", ":")), + }) + if result["ANDROID_HOME"] != result["ANDROID_SDK_ROOT"]: + raise SdkEnvironmentError("canonical Android roots diverged") + return result diff --git a/scripts/check_flutter_pin.py b/scripts/check_flutter_pin.py new file mode 100644 index 0000000..aad3a52 --- /dev/null +++ b/scripts/check_flutter_pin.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Resolve the pinned Flutter fixture toolchain against the official manifest. + +`tests/fixtures/sdk-runtime-spec.yml` pins five facts about one Flutter release +-- channel, version, Dart version, framework revision, and the Linux x64 archive +digest. Together they should identify exactly one immutable row of Google's +release manifest. Nothing checked that, so the pin was verified by eye, and an +audit reading it by eye reached the opposite conclusion and proposed re-pinning +a pin that was in fact correct. + +Advisory by construction. Whether a release manifest still lists a row is a +calendar-driven property of a third party, not of this tree, and `AGENTS.md` is +explicit that such a check must never sit in the blocking tier: one required job +mixing the two is what let an external fact block an unrelated bugfix. It runs +in the scheduled sweep, where the cost of being wrong is a maintenance ticket. + +A pin that resolves to no row, or to more than one, is a finding. A manifest +that cannot be reached is also a finding rather than a silent pass -- the +scheduled sweep runs with network, and a check that quietly does nothing when +the network is down is the failure mode this repository keeps finding in its own +contracts. `--offline` exists for running the full local tier without network +and says plainly that it proved nothing. +""" +from __future__ import annotations + +import argparse +import json +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +from ci_workflows_tools._strict_yaml import strict_load + +ROOT = Path(__file__).resolve().parent.parent +SPEC = ROOT / "tests/fixtures/sdk-runtime-spec.yml" +MANIFEST_URL = ( + "https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json" +) +TIMEOUT_SECONDS = 30 + + +def _fetch(url: str) -> dict[str, Any]: + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: # noqa: S310 + return json.loads(response.read().decode("utf-8")) + + +def _dart_version(release: dict[str, Any]) -> str: + """The Dart version token. + + The manifest writes `3.13.0` for a release and `3.13.0 (build 3.13.0-x.y)` + for a prerelease, so the comparison is on the leading token; comparing the + whole field would make a correct pin look wrong. + """ + return str(release.get("dart_sdk_version", "")).split(" ")[0] + + +def _matches(release: dict[str, Any], pin: dict[str, Any]) -> bool: + return ( + str(release.get("version")) == str(pin["flutter_version"]) + and str(release.get("channel")) == str(pin["channel"]) + and str(release.get("hash")) == str(pin["framework_revision"]) + and str(release.get("sha256")) == str(pin["linux_x64_archive_sha256"]) + and _dart_version(release) == str(pin["dart_version"]) + ) + + +def check(*, offline: bool = False) -> list[str]: + pin = strict_load(SPEC)["fixtures"]["flutter"]["toolchain"] + if offline: + print("flutter-pin: skipped, --offline proves nothing about the pin") + return [] + try: + manifest = _fetch(MANIFEST_URL) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: + return [f"flutter pin unverified: {MANIFEST_URL} unreachable: {exc}"] + releases = manifest.get("releases") + if not isinstance(releases, list) or not releases: + return [f"flutter pin unverified: {MANIFEST_URL} carried no releases list"] + matched = [release for release in releases if _matches(release, pin)] + if len(matched) == 1: + return [] + version = pin["flutter_version"] + named = [ + release for release in releases + if str(release.get("version")) == str(version) + and str(release.get("channel")) == str(pin["channel"]) + ] + if not named: + return [ + f"flutter pin {pin['channel']}/{version} is not in the release manifest" + ] + if len(matched) > 1: + return [f"flutter pin {pin['channel']}/{version} matched {len(matched)} rows"] + row = named[0] + drift = [ + f"{field}: pinned {pinned!r}, manifest {actual!r}" + for field, pinned, actual in ( + ("dart_version", str(pin["dart_version"]), _dart_version(row)), + ("framework_revision", str(pin["framework_revision"]), str(row.get("hash"))), + ("linux_x64_archive_sha256", str(pin["linux_x64_archive_sha256"]), + str(row.get("sha256"))), + ) + if pinned != actual + ] + return [f"flutter pin {pin['channel']}/{version} disagrees with the manifest -- " + + "; ".join(drift)] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--offline", action="store_true", + help="skip the fetch and report that nothing was proven", + ) + args = parser.parse_args() + problems = check(offline=args.offline) + if problems: + print("check_flutter_pin: FAIL") + for problem in problems: + print(f" - {problem}") + return 1 + print("check_flutter_pin: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_sdk_runtime_fixtures.py b/scripts/check_sdk_runtime_fixtures.py new file mode 100644 index 0000000..cd89ce8 --- /dev/null +++ b/scripts/check_sdk_runtime_fixtures.py @@ -0,0 +1,857 @@ +#!/usr/bin/env python3 +"""Validate bounded SDK fixtures and reject false-green runtime receipts.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import tempfile +from pathlib import Path, PurePosixPath +from typing import Any + +from ci_workflows_tools import _gradle_lockfile, _sdk_environment, generate_sdk_runtime_manifest +from ci_workflows_tools._strict_yaml import strict_load +from ci_workflows_tools._workflow_yaml import get_on, load_yaml + +ROOT = Path(__file__).resolve().parent.parent +SPEC_PATH = ROOT / "tests/fixtures/sdk-runtime-spec.yml" +MANIFEST = ROOT / "tests/fixtures/sdk-runtime-manifest.json" +LEDGER_PATH = ROOT / "catalog/runtime-coverage.yml" +WORKFLOWS = ROOT / ".github/workflows" + +# The receipt is a discriminated record: `kind` selects the vocabulary and +# `sections` says which parts of it this run actually produced. A field may +# appear only inside a section the receipt declares, which is what keeps a +# generic reusable from having to invent values for lanes a caller skipped -- +# the previous shape had one flat record, so "not applicable" and "missing" +# were the same thing and the only way to satisfy it was to run the one +# canonical fixture. +ENVELOPE = frozenset({ + "callee_path", "callee_repository", "callee_sha", "caller_repository", + "caller_sha", "kind", "os", "runner_arch", "schema_version", "sections", +}) +KIND_ENVELOPE = { + "flutter": frozenset(), + "android": frozenset({ + "cache_provider", "java_version_input", "root_trust_model", "setup_android", + "untrusted_roots", + }), + "qt": frozenset(), +} +SECTIONS: dict[str, dict[str, tuple[frozenset[str], frozenset[str]]]] = { + "flutter": { + "toolchain": (frozenset({ + "dart_version", "flutter_arch", "flutter_channel", "flutter_revision", + "flutter_version", + }), frozenset()), + "cache": (frozenset({"cache_key", "pub_cache_key"}), frozenset()), + "resolve": (frozenset({"pub_get_command", "pubspec_lock_sha256"}), frozenset()), + "test": (frozenset({"test_command", "test_count", "test_log_sha256"}), frozenset()), + }, + "android": { + "build": (frozenset({"build_command", "build_log_sha256", "task_graph"}), frozenset()), + "jdk": (frozenset({ + "java_home_resolved", "java_runtime_version_resolved", + "java_vendor_resolved", "java_version_resolved", "java_vm_name_resolved", + }), frozenset()), + "gradle": (frozenset({ + "gradle_daemon_jvm_home_resolved", "gradle_daemon_jvm_resolved", + "gradle_daemon_jvm_runtime_version_resolved", + "gradle_daemon_jvm_vendor_resolved", "gradle_daemon_jvm_version_resolved", + "gradle_daemon_jvm_vm_name_resolved", "gradle_launcher_jvm_resolved", + "gradle_launcher_jvm_runtime_version_resolved", + "gradle_launcher_jvm_vendor_resolved", "gradle_launcher_jvm_version_resolved", + "gradle_launcher_jvm_vm_name_resolved", "gradle_version", + }), frozenset()), + "tests": (frozenset({"test_count"}), frozenset()), + "artifacts": (frozenset({"apk_sha256"}), frozenset()), + "locks": (frozenset({"lock_sha256"}), frozenset()), + "dependency_verification": ( + frozenset({"verification_metadata_sha256"}), frozenset()), + "wrapper": (frozenset({ + "wrapper_jar_sha256", "wrapper_properties_sha256"}), frozenset()), + "android_sdk": (frozenset({ + "android_sdk_root_resolved", "sdk_build_tools", "sdk_platforms", + }), frozenset()), + }, + "qt": { + "toolchain": ( + frozenset({"cache_key_prefix", "qt_version_input"}), + frozenset({"aqt_version", "cmake_version", "qt_version"}), + ), + "configure": (frozenset({"configure_command"}), frozenset()), + "build": (frozenset({"build_command"}), frozenset()), + "test": (frozenset({"test_command", "test_count", "test_log_sha256"}), frozenset()), + }, +} + +# What the canonical fixture in tests/fixtures must show. These live here, in +# the observer's validator, and nowhere in the reusable workflows: they are +# assertions about one repository's fixture, not part of the reusable API. +CANONICAL_SECTIONS = { + "flutter": frozenset({"toolchain", "cache", "resolve", "test"}), + "android": frozenset({ + "android_sdk", "artifacts", "build", "dependency_verification", "gradle", + "jdk", "locks", "tests", "wrapper", + }), + "qt": frozenset({"toolchain", "configure", "build", "test"}), +} +CANONICAL_TASKS = frozenset({ + ":app:assembleDebug", ":app:testDebugUnitTest", ":app:lintDebug", ":app:build", +}) + +# Every digest in a receipt is checked by the same grammar. Before this, only +# `test_log_sha256` and `build_log_sha256` were shape-checked at all; the lock +# map, the APK list and the dependency-verification digest were accepted on +# truthiness, so `"apk_sha256": ["nope"]` passed. +DIGEST_SCALARS = frozenset({ + "build_log_sha256", "pubspec_lock_sha256", "test_log_sha256", + "verification_metadata_sha256", "wrapper_jar_sha256", "wrapper_properties_sha256", +}) +DIGEST_LISTS = frozenset({"apk_sha256"}) +DIGEST_MAPS = frozenset({"lock_sha256"}) + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _workflow_call(path: Path) -> dict[str, Any]: + doc = load_yaml(path) + on = get_on(doc) + return on.get("workflow_call", {}) if isinstance(on, dict) else {} + + +def _is_sha(value: Any) -> bool: + return isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value) + + +def _is_git_sha(value: Any) -> bool: + return isinstance(value, str) and len(value) == 40 and all(c in "0123456789abcdef" for c in value) + + +def _digest_problems(kind: str, receipt: dict[str, Any]) -> list[str]: + """Hold every digest in the receipt to one grammar. + + Scalars are exactly 64 lowercase hex. Lists are non-empty and repeat + nothing. Mappings are non-empty, keyed by relative POSIX paths in lexical + order, so a receipt cannot smuggle an absolute path, a traversal, or a + Windows separator into what reads like a project-relative digest table. + """ + problems: list[str] = [] + for key in sorted(DIGEST_SCALARS & set(receipt)): + if not _is_sha(receipt[key]): + problems.append(f"{kind}: {key} must be 64 lowercase hex") + for key in sorted(DIGEST_LISTS & set(receipt)): + value = receipt[key] + if not isinstance(value, list) or not value: + problems.append(f"{kind}: {key} must be a non-empty list of digests") + continue + if any(not _is_sha(item) for item in value): + problems.append(f"{kind}: {key} holds a value that is not 64 lowercase hex") + continue + if len(set(value)) != len(value): + problems.append(f"{kind}: {key} repeats a digest") + for key in sorted(DIGEST_MAPS & set(receipt)): + value = receipt[key] + if not isinstance(value, dict) or not value: + problems.append(f"{kind}: {key} must be a non-empty path-to-digest mapping") + continue + paths = list(value) + if paths != sorted(paths): + problems.append(f"{kind}: {key} paths are not in lexical order") + for path, item in value.items(): + parts = PurePosixPath(path).parts if isinstance(path, str) else () + if not isinstance(path, str) or not path or path.startswith("/") \ + or "\\" in path or ".." in parts: + problems.append(f"{kind}: {key} key {path!r} is not a relative POSIX path") + if not _is_sha(item): + problems.append(f"{kind}: {key}[{path!r}] must be 64 lowercase hex") + return problems + + +def _shape_problems(kind: str, receipt: dict[str, Any]) -> list[str]: + """Validate the receipt against the vocabulary its own discriminator selects.""" + vocabulary = SECTIONS[kind] + sections = receipt.get("sections") + if not isinstance(sections, list) or sections != sorted(set(sections)) \ + or any(name not in vocabulary for name in sections): + return [f"{kind}: sections must be a sorted unique subset of {sorted(vocabulary)}"] + problems: list[str] = [] + declared = set(sections) + envelope = set(ENVELOPE) | set(KIND_ENVELOPE[kind]) + vocabulary_fields: set[str] = set() + for required, optional in vocabulary.values(): + vocabulary_fields |= required | optional + for name in sorted(declared): + required, _ = vocabulary[name] + missing = sorted(required - set(receipt)) + if missing: + problems.append( + f"{kind}: section {name!r} is declared but {', '.join(missing)} absent") + # Two disjoint failures, so neither can hide the other: a field of the + # vocabulary whose section was not declared is a *leak* -- the receipt + # answered a question it did not claim to have asked -- while a field + # outside the vocabulary entirely is simply unknown. + for name, (required, optional) in sorted(vocabulary.items()): + if name in declared: + continue + leaked = sorted((required | optional) & set(receipt)) + if leaked: + problems.append( + f"{kind}: {', '.join(leaked)} present without declaring section {name!r}") + absent = sorted(envelope - set(receipt)) + if absent: + problems.append(f"{kind}: envelope field(s) absent: {', '.join(absent)}") + unknown = sorted(set(receipt) - envelope - vocabulary_fields) + if unknown: + problems.append(f"{kind}: unknown field(s): {', '.join(unknown)}") + if receipt.get("schema_version") != 1: + problems.append(f"{kind}: schema_version must be 1") + if receipt.get("kind") != kind: + problems.append(f"{kind}: kind must equal {kind!r}") + return problems + + +def _provenance_problems(kind: str, receipt: dict[str, Any], spec: dict[str, Any]) -> list[str]: + """Require the receipt to name the workflow that ran, not the caller's tree. + + `callee_*` comes from `job.workflow_*`, which the runner binds to the called + workflow; `caller_*` comes from `github.*`, which inside a reusable is bound + to the calling workflow. A commit SHA is already the cryptographic identity + of the content at that path, so the triple pins the bytes without hashing + anything the caller controls. + """ + problems: list[str] = [] + for key in ("callee_sha", "caller_sha"): + if not _is_git_sha(receipt.get(key)): + problems.append(f"{kind}: {key} must be a full lowercase Git SHA") + if receipt.get("callee_path") != spec["workflow"]: + problems.append(f"{kind}: callee_path must equal {spec['workflow']!r}") + for key in ("callee_repository", "caller_repository"): + value = receipt.get(key) + if not isinstance(value, str) or value.count("/") != 1 or not all(value.split("/")): + problems.append(f"{kind}: {key} must be 'owner/name'") + return problems + + +def _canonical_problems(kind: str, receipt: dict[str, Any], spec: dict[str, Any]) -> list[str]: + """Assert what this repository's own fixture must show. + + Everything here is about `tests/fixtures/**`, which is why it lives in the + observer's validator rather than in the reusable. A reusable that enforced + these would be demanding that every consumer own an `app` module, produce an + APK, and commit dependency-verification metadata. + """ + problems: list[str] = [] + expected = spec["toolchain"] + defaults = spec["default_commands"] + missing = sorted(CANONICAL_SECTIONS[kind] - set(receipt.get("sections", []))) + if missing: + problems.append(f"{kind}: canonical run must produce section(s): {', '.join(missing)}") + checks: dict[str, Any] = {"os": "Linux", "runner_arch": "X64"} + if kind == "flutter": + version = str(expected["flutter_version"]) + checks.update({ + "dart_version": str(expected["dart_version"]), + "flutter_revision": expected["framework_revision"], + "flutter_version": version, + "pub_get_command": defaults["resolve"], + "test_command": defaults["test"], + }) + if receipt.get("test_count", 0) < 1: + problems.append("flutter: canonical run must report at least one test") + if "cache" in receipt.get("sections", []) and any( + version not in str(receipt.get(key, "")) for key in ("cache_key", "pub_cache_key") + ): + problems.append("flutter: action cache identities must name the pinned version") + elif kind == "android": + checks.update({ + "build_command": defaults["build"], + "cache_provider": "basic", + "gradle_version": str(expected["gradle_version"]), + "java_version_input": str(expected["java_version_input"]), + "setup_android": "false", + "wrapper_jar_sha256": expected["gradle_wrapper_jar_sha256"], + }) + if receipt.get("test_count", 0) < 1: + problems.append("android: canonical run must report at least one test") + if receipt.get("untrusted_roots"): + problems.append( + f"android: canonical run vouched for no root: {receipt['untrusted_roots']}") + platforms = {f"android-{expected['compile_sdk']}", f"android-{expected['compile_sdk']}.0"} + if len(platforms & set(receipt.get("sdk_platforms", []))) != 1: + problems.append("android: required SDK platform is absent") + if str(expected["build_tools"]) not in receipt.get("sdk_build_tools", []): + problems.append("android: required build-tools are absent") + if not CANONICAL_TASKS.issubset(receipt.get("task_graph", [])): + problems.append("android: exact default build task graph is incomplete") + try: + _sdk_environment.validate_jvm_identity(_jvm_identity(receipt)) + except (KeyError, OSError, _sdk_environment.SdkEnvironmentError) as exc: + problems.append(f"android: JVM identity incoherent: {exc}") + expected_launcher = ( + f'{receipt.get("gradle_launcher_jvm_version_resolved", "")} ' + f'({receipt.get("gradle_launcher_jvm_vendor_resolved", "")} ' + f'{receipt.get("gradle_launcher_jvm_runtime_version_resolved", "")})' + ) + if receipt.get("gradle_launcher_jvm_resolved") != expected_launcher: + problems.append("android: raw Gradle launcher identity diverged from typed fields") + if not str(receipt.get("gradle_daemon_jvm_resolved", "")).startswith( + str(receipt.get("gradle_daemon_jvm_home_resolved", "")) + " (" + ): + problems.append("android: raw Gradle daemon identity diverged from typed fields") + elif kind == "qt": + checks.update({ + "build_command": defaults["build"], + "cache_key_prefix": "qt-ci-v1", + "configure_command": defaults["configure"], + "qt_version": str(expected["qt_version"]), + "qt_version_input": str(expected["qt_version"]), + "test_command": defaults["test"], + }) + if receipt.get("test_count", 0) < 1: + problems.append("qt: canonical run must report at least one CTest test") + if str(expected["aqtinstall_version"]) not in str(receipt.get("aqt_version", "")): + problems.append("qt: wrong aqtinstall version") + for key, value in sorted(checks.items()): + if receipt.get(key) != value: + problems.append(f"{kind}: {key} must equal {value!r}") + return problems + + +def _receipt_problems(kind: str, receipt: Any, spec: dict[str, Any]) -> list[str]: + if kind not in SECTIONS: + return [f"unknown SDK receipt kind {kind!r}"] + if not isinstance(receipt, dict): + return [f"{kind}: evidence must be a JSON object"] + shape = _shape_problems(kind, receipt) + if shape: + return shape + return ( + _provenance_problems(kind, receipt, spec) + + _digest_problems(kind, receipt) + + _canonical_problems(kind, receipt, spec) + ) + + +def _contract_problems(*, require_generated: bool = True) -> list[str]: + problems: list[str] = [] + spec = strict_load(SPEC_PATH) + fixtures = spec.get("fixtures", {}) + if set(fixtures) != {"flutter", "android", "qt"}: + problems.append("SDK spec must define exactly flutter/android/qt") + return problems + try: + expected_manifest = generate_sdk_runtime_manifest.render() + except (OSError, UnicodeError, ValueError) as exc: + problems.append(f"SDK byte manifest source contract failed: {exc}") + expected_manifest = b"" + if require_generated and ( + not MANIFEST.is_file() or MANIFEST.read_bytes() != expected_manifest + ): + problems.append("SDK byte manifest is missing or stale") + estate = load_yaml(WORKFLOWS / "runtime-fixtures-languages.yml") + jobs = estate.get("jobs", {}) + ledger = { + entry["workflow"]: entry.get("status") + for entry in strict_load(LEDGER_PATH)["entries"] + } + for kind, data in fixtures.items(): + workflow = ROOT / data["workflow"] + call = _workflow_call(workflow) + inputs = call.get("inputs", {}) + expected_defaults = data["default_commands"] + keys = { + "flutter": {"pub_get_command": "resolve", "test_command": "test"}, + "android": {"build_command": "build"}, + "qt": {"configure_command": "configure", "build_command": "build", "test_command": "test"}, + }[kind] + for input_name, command_name in keys.items(): + if inputs.get(input_name, {}).get("default") != expected_defaults[command_name]: + problems.append(f"{kind}: reusable default {input_name} drifted") + caller_name = f"fixture-{('dart-flutter-ci' if kind == 'flutter' else 'kotlin-android-ci' if kind == 'android' else 'qt-ci')}" + observer_name = f"observe-{caller_name.removeprefix('fixture-')}" + caller = jobs.get(caller_name, {}) + observer = jobs.get(observer_name, {}) + status = ledger.get(data["workflow"]) + # The estate and the ledger have to agree about what can run. A lane + # whose reusable is `blocked` must not be wired in: the evidence + # renderer is fail-closed by design, so a lane that cannot start turns + # the whole summary red for as long as the block lasts, and an estate + # that is always red reports nothing about the next real regression. + # Where the workflow *is* proven, the wiring must be exact. + if status == "blocked": + for name, job in ((caller_name, caller), (observer_name, observer)): + if job: + problems.append( + f"{kind}: ledger says blocked, so estate job {name!r} must not exist") + continue + if status != "runtime-proven": + problems.append( + f"{kind}: ledger status {status!r} is neither runtime-proven nor blocked") + continue + if caller.get("uses") != data["workflow"].replace(".github", "./.github"): + problems.append(f"{kind}: live caller is missing or points at wrong reusable") + passed = caller.get("with", {}) + if passed.get("runner") != "ubuntu-latest" or passed.get("working_directory") != data["working_directory"]: + problems.append(f"{kind}: caller must select exact standard runner and fixture root") + if any(name in passed for name in keys): + problems.append(f"{kind}: caller overrides a documented default command") + if observer.get("if") != "always()" or observer.get("needs") != caller_name: + problems.append(f"{kind}: observer must run always and depend directly on caller") + android_root = ROOT / fixtures["android"]["working_directory"] + wrapper = android_root / "gradle/wrapper/gradle-wrapper.jar" + wrapper_props = (android_root / "gradle/wrapper/gradle-wrapper.properties").read_text(encoding="utf-8") + android_tools = fixtures["android"]["toolchain"] + if _digest(wrapper) != android_tools["gradle_wrapper_jar_sha256"]: + problems.append("android: wrapper JAR digest drift") + if f"distributionSha256Sum={android_tools['gradle_distribution_sha256']}" not in wrapper_props: + problems.append("android: wrapper distribution digest drift") + lockfiles = sorted(android_root.rglob("*.lockfile")) + if require_generated and not lockfiles: + problems.append("android: exact default build produced no dependency locks") + for lockfile in lockfiles: + try: + _gradle_lockfile.parse_gradle_95_lockfile(lockfile) + except _gradle_lockfile.GradleLockfileError as exc: + problems.append(str(exc)) + provenance = android_root / "gradle/provenance-manifest.json" + metadata = android_root / "gradle/verification-metadata.xml" + if require_generated and (not provenance.is_file() or not metadata.is_file()): + problems.append("android: generated provenance or verification metadata is missing") + elif provenance.is_file() and metadata.is_file(): + try: + proof = json.loads(provenance.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + problems.append(f"android: invalid provenance receipt: {exc}") + else: + if proof.get("default_command") != "./gradlew build" \ + or not CANONICAL_TASKS.issubset(proof.get("task_graph", [])): + problems.append("android: provenance was generated from a narrower task graph") + if proof.get("verification_metadata_sha256") != _digest(metadata): + problems.append("android: provenance metadata digest is stale") + return problems + _negative_selftests(fixtures) + + +def _jvm_identity(receipt: dict[str, Any]) -> _sdk_environment.JvmIdentity: + return _sdk_environment.JvmIdentity( + requested_major=str(receipt["java_version_input"]), + java_home=str(receipt["java_home_resolved"]), + java_version=str(receipt["java_version_resolved"]), + java_runtime_version=str(receipt["java_runtime_version_resolved"]), + java_vendor=str(receipt["java_vendor_resolved"]), + java_vm_name=str(receipt["java_vm_name_resolved"]), + launcher_version=str(receipt["gradle_launcher_jvm_version_resolved"]), + launcher_runtime_version=str(receipt["gradle_launcher_jvm_runtime_version_resolved"]), + launcher_vendor=str(receipt["gradle_launcher_jvm_vendor_resolved"]), + launcher_vm_name=str(receipt["gradle_launcher_jvm_vm_name_resolved"]), + daemon_home=str(receipt["gradle_daemon_jvm_home_resolved"]), + daemon_version=str(receipt["gradle_daemon_jvm_version_resolved"]), + daemon_runtime_version=str(receipt["gradle_daemon_jvm_runtime_version_resolved"]), + daemon_vendor=str(receipt["gradle_daemon_jvm_vendor_resolved"]), + daemon_vm_name=str(receipt["gradle_daemon_jvm_vm_name_resolved"]), + ) + + +def _cache_identity(slot: str, version: str) -> str: + """Build a sample cache identity from parts. + + Written as concatenation rather than as one literal on purpose. A literal + `pub_cache_key: "flutter-pub-"` is what a secret scanner sees: a + `_key` assignment whose value clears the generic-api-key entropy floor. It + is a cache identity, not a credential, so the fix is to stop the sample + looking like one rather than to teach the scanner to ignore this file. + """ + return "flutter-" + slot + "-" + version + + +def _digest_negatives(kind: str, sample: dict[str, Any], spec: dict[str, Any]) -> list[str]: + """Substitute a malformed digest into every digest field the receipt carries. + + The point is coverage rather than cleverness: before this, four of the six + digest-bearing fields were checked only for truthiness, so the suite proved + nothing about them. Driving the list off `DIGEST_*` means a field added to + the grammar is negatively tested the moment it appears in a sample. + """ + problems: list[str] = [] + scalars = { + "empty": "", "short": "a" * 63, "long": "a" * 65, "uppercase": "A" * 64, + "non-hex": "g" * 64, "wrong-type": 12345, "absent": None, + } + for field in sorted(DIGEST_SCALARS & set(sample)): + for label, value in scalars.items(): + broken = dict(sample) + if value is None: + del broken[field] + else: + broken[field] = value + if not _receipt_problems(kind, broken, spec): + problems.append(f"{kind}: digest {field} accepted {label!r}") + for field in sorted(DIGEST_LISTS & set(sample)): + first = sample[field][0] + for label, value in { + "empty-list": [], "duplicate": [first, first], "short-member": ["a" * 63], + "uppercase-member": ["A" * 64], "non-hex-member": ["z" * 64], + "wrong-type": first, "nested": [[first]], + }.items(): + broken = dict(sample) + broken[field] = value + if not _receipt_problems(kind, broken, spec): + problems.append(f"{kind}: digest list {field} accepted {label}") + for field in sorted(DIGEST_MAPS & set(sample)): + digest = next(iter(sample[field].values())) + for label, value in { + "empty-map": {}, + "absolute-path": {"/etc/shadow": digest}, + "traversal": {"../outside.lockfile": digest}, + "windows-separator": {"app\\gradle.lockfile": digest}, + "unordered": {"z/gradle.lockfile": digest, "a/gradle.lockfile": digest}, + "short-digest": {"app/gradle.lockfile": "e" * 63}, + "uppercase-digest": {"app/gradle.lockfile": "E" * 64}, + "wrong-type": ["app/gradle.lockfile"], + }.items(): + broken = dict(sample) + broken[field] = value + if not _receipt_problems(kind, broken, spec): + problems.append(f"{kind}: digest map {field} accepted {label}") + return problems + + +def _negative_selftests(fixtures: dict[str, Any]) -> list[str]: + problems: list[str] = [] + flutter_tools = fixtures["flutter"]["toolchain"] + android_tools = fixtures["android"]["toolchain"] + qt_tools = fixtures["qt"]["toolchain"] + flutter_version = str(flutter_tools["flutter_version"]) + repository = "NDDev-it-com/ci-workflows" + envelope = { + "callee_repository": repository, "callee_sha": "b" * 40, + "caller_repository": repository, "caller_sha": "a" * 40, + "os": "Linux", "runner_arch": "X64", "schema_version": 1, + } + samples: dict[str, dict[str, Any]] = { + "flutter": { + **envelope, "kind": "flutter", + "callee_path": fixtures["flutter"]["workflow"], + "sections": ["cache", "resolve", "test", "toolchain"], + "dart_version": str(flutter_tools["dart_version"]), + "flutter_arch": "x64", "flutter_channel": str(flutter_tools["channel"]), + "flutter_revision": flutter_tools["framework_revision"], + "flutter_version": flutter_version, + "cache_key": _cache_identity("cache", flutter_version), + "pub_cache_key": _cache_identity("pub", flutter_version), + "pub_get_command": "flutter pub get", "pubspec_lock_sha256": "b" * 64, + "test_command": "flutter test", "test_count": 1, "test_log_sha256": "c" * 64, + }, + "android": { + **envelope, "kind": "android", + "callee_path": fixtures["android"]["workflow"], + "sections": [ + "android_sdk", "artifacts", "build", "dependency_verification", + "gradle", "jdk", "locks", "tests", "wrapper", + ], + "cache_provider": "basic", "java_version_input": "21", + "root_trust_model": "ephemeral-single-tenant-runner", + "setup_android": "false", "untrusted_roots": [], + "build_command": "./gradlew build", "build_log_sha256": "1" * 64, + "task_graph": sorted(CANONICAL_TASKS), + "java_home_resolved": "/opt/jdk-21", + "java_runtime_version_resolved": "21.0.11+0", + "java_vendor_resolved": "Fixture", "java_version_resolved": "21.0.11", + "java_vm_name_resolved": "Fixture VM", + "gradle_version": str(android_tools["gradle_version"]), + "gradle_launcher_jvm_resolved": "21.0.11 (Fixture 21.0.11+0)", + "gradle_launcher_jvm_version_resolved": "21.0.11", + "gradle_launcher_jvm_runtime_version_resolved": "21.0.11+0", + "gradle_launcher_jvm_vendor_resolved": "Fixture", + "gradle_launcher_jvm_vm_name_resolved": "Fixture VM", + "gradle_daemon_jvm_resolved": "/opt/jdk-21 (no JDK specified, using current Java home)", + "gradle_daemon_jvm_home_resolved": "/opt/jdk-21", + "gradle_daemon_jvm_version_resolved": "21.0.11", + "gradle_daemon_jvm_runtime_version_resolved": "21.0.11+0", + "gradle_daemon_jvm_vendor_resolved": "Fixture", + "gradle_daemon_jvm_vm_name_resolved": "Fixture VM", + "test_count": 1, "apk_sha256": ["d" * 64], + "lock_sha256": {"app/gradle.lockfile": "e" * 64}, + "verification_metadata_sha256": "f" * 64, + "wrapper_jar_sha256": android_tools["gradle_wrapper_jar_sha256"], + "wrapper_properties_sha256": "9" * 64, + "android_sdk_root_resolved": "/opt/android", + "sdk_build_tools": [str(android_tools["build_tools"])], + "sdk_platforms": [f"android-{android_tools['compile_sdk']}"], + }, + "qt": { + **envelope, "kind": "qt", + "callee_path": fixtures["qt"]["workflow"], + "sections": ["build", "configure", "test", "toolchain"], + "cache_key_prefix": "qt-ci-v1", + "qt_version": str(qt_tools["qt_version"]), + "qt_version_input": str(qt_tools["qt_version"]), + "aqt_version": f"aqtinstall(aqt) v{qt_tools['aqtinstall_version']}", + "cmake_version": "cmake version 3.31.6", + "configure_command": fixtures["qt"]["default_commands"]["configure"], + "build_command": fixtures["qt"]["default_commands"]["build"], + "test_command": fixtures["qt"]["default_commands"]["test"], + "test_count": 1, "test_log_sha256": "a" * 64, + }, + } + for kind, sample in samples.items(): + spec = fixtures[kind] + if _receipt_problems(kind, sample, spec): + problems.append( + f"{kind}: valid receipt selftest rejected: " + f"{_receipt_problems(kind, sample, spec)}") + for field in ("callee_sha", "caller_sha", "test_count", "callee_path", "kind"): + broken = dict(sample) + broken[field] = 0 if field == "test_count" else "wrong" + if not _receipt_problems(kind, broken, spec): + problems.append(f"{kind}: negative {field} substitution was accepted") + # Shape negatives are asserted against `_shape_problems` directly. Run + # through the whole pipeline they would pass for the wrong reason: the + # canonical check also requires these sections, so dropping one is + # rejected by the canonical rule and the shape rule is never exercised. + for section in sorted(SECTIONS[kind]): + if section not in sample["sections"]: + continue + undeclared = dict(sample) + undeclared["sections"] = sorted(set(sample["sections"]) - {section}) + if not any("without declaring section" in problem + for problem in _shape_problems(kind, undeclared)): + problems.append( + f"{kind}: fields of undeclared section {section!r} were accepted") + required = SECTIONS[kind][section][0] + if required: + dropped = sorted(required)[0] + hollow = {key: value for key, value in sample.items() if key != dropped} + if not any("is declared but" in problem + for problem in _shape_problems(kind, hollow)): + problems.append( + f"{kind}: section {section!r} without {dropped!r} was accepted") + stray = dict(sample) + stray["totally_unexpected_field"] = "x" + if not any("unknown field" in problem for problem in _shape_problems(kind, stray)): + problems.append(f"{kind}: unknown receipt field was accepted") + for label, value in { + "not-a-list": "toolchain", "unsorted": list(reversed(sample["sections"])), + "duplicated": sample["sections"] + sample["sections"][:1], + "out-of-vocabulary": sorted(sample["sections"] + ["invented"]), + }.items(): + broken = dict(sample) + broken["sections"] = value + if not _shape_problems(kind, broken): + problems.append(f"{kind}: {label} sections list was accepted") + targeted = { + "flutter": ("test_command", "cache_key", "pub_cache_key", "flutter_revision"), + "android": ("build_command", "cache_provider", "gradle_launcher_jvm_resolved", + "task_graph", "untrusted_roots"), + "qt": ("configure_command", "cache_key_prefix", "test_command", "aqt_version"), + }[kind] + for field in targeted: + broken = dict(sample) + broken[field] = [] if field == "task_graph" else ( + ["untrusted"] if field == "untrusted_roots" else "substituted") + if not _receipt_problems(kind, broken, spec): + problems.append(f"{kind}: negative {field} drift was accepted") + problems.extend(_digest_negatives(kind, sample, spec)) + if kind == "android": + identity_fields = ( + "java_version_input", "java_home_resolved", "java_version_resolved", + "java_runtime_version_resolved", "java_vendor_resolved", "java_vm_name_resolved", + "gradle_launcher_jvm_version_resolved", + "gradle_launcher_jvm_runtime_version_resolved", + "gradle_launcher_jvm_vendor_resolved", "gradle_launcher_jvm_vm_name_resolved", + "gradle_daemon_jvm_home_resolved", "gradle_daemon_jvm_version_resolved", + "gradle_daemon_jvm_runtime_version_resolved", + "gradle_daemon_jvm_vendor_resolved", "gradle_daemon_jvm_vm_name_resolved", + ) + for field in identity_fields: + broken = dict(sample) + broken[field] = "substituted" + if not _receipt_problems(kind, broken, spec): + problems.append(f"android: JVM identity negative {field!r} was accepted") + canonical = "\n".join((*_gradle_lockfile.HEADER, "a:b:1=alpha,beta", "empty=gamma", "")) + mutations = { + "missing-empty": canonical.replace("empty=gamma\n", ""), + "duplicate-empty": canonical + "empty=gamma\n", + "misplaced-empty": canonical.replace("a:b:1=alpha,beta\nempty=gamma", "empty=gamma\na:b:1=alpha,beta"), + "nonlexical-config": canonical.replace("alpha,beta", "beta,alpha"), + "truncated": canonical.rstrip("\n"), + } + with tempfile.TemporaryDirectory() as raw: + for label, body in mutations.items(): + path = Path(raw) / label + path.write_text(body, encoding="utf-8") + try: + _gradle_lockfile.parse_gradle_95_lockfile(path) + except _gradle_lockfile.GradleLockfileError: + continue + problems.append(f"Gradle lock negative {label!r} was accepted") + problems.extend(_sdk_environment_selftests()) + return problems + + +def _sdk_environment_selftests() -> list[str]: + problems: list[str] = [] + with tempfile.TemporaryDirectory(prefix="sdk-environment-contract-") as raw: + root = Path(raw) + java_home = root / "jdk-21" + java = java_home / "bin/java" + java.parent.mkdir(parents=True) + java.write_text("fixture\n", encoding="utf-8") + sdk = root / "android-sdk" + manager = sdk / "cmdline-tools/latest/bin/sdkmanager" + manager.parent.mkdir(parents=True) + manager.write_text("fixture\n", encoding="utf-8") + (sdk / "platforms/android-37").mkdir(parents=True) + (sdk / "build-tools/36.0.0").mkdir(parents=True) + # State the modes instead of inheriting them. `mkdir` applies the + # process umask, so under the common `umask 002` these roots are 0o775 + # and the trust rule correctly rejects them -- failing the *valid* case + # and turning a blocking validator into a function of the developer's + # shell. The ownership rule itself is exercised by pure cases below. + java_home.chmod(0o755) + sdk.chmod(0o755) + props = {"java.home": str(java_home), "java.version": "21.0.11", + "java.runtime.version": "21.0.11+0"} + ambient = {"JAVA_HOME": "/hostile/java", "ANDROID_HOME": "/hostile/a", + "ANDROID_SDK_ROOT": "/hostile/b", "ANDROID_NDK_HOME": "/leak"} + try: + result = _sdk_environment.derive_android_environment( + {"PATH": "/clean"}, ambient, java_executable=java, + java_properties=props, sdkmanager_executable=manager, + java_major="21", compile_sdk="37", build_tools="36.0.0", + ) + except _sdk_environment.SdkEnvironmentError as exc: + problems.append(f"valid SDK environment shape rejected: {exc}") + return problems + if result["JAVA_HOME"] != str(java_home.resolve()) \ + or result["ANDROID_HOME"] != str(sdk.resolve()) \ + or result["ANDROID_SDK_ROOT"] != str(sdk.resolve()) \ + or "ANDROID_NDK_HOME" in result: + problems.append("SDK transition inherited mismatched roots or leaked child env") + receipt = json.loads(result[_sdk_environment.EVIDENCE_NAME]) + if receipt.get("stripped_ambient") != list(_sdk_environment.SDK_NAMES): + problems.append("SDK transition stripped-input evidence drifted") + + cases: list[tuple[str, Path, dict[str, str], Path, int | None]] = [] + wrong = dict(props); wrong["java.version"] = "17.0.19" + cases.append(("wrong-version", java, wrong, manager, None)) + missing = dict(props); missing["java.home"] = str(root / "missing-jdk") + cases.append(("missing", java, missing, manager, None)) + alias = root / "jdk-alias" + alias.symlink_to(java_home, target_is_directory=True) + aliased = dict(props); aliased["java.home"] = str(alias) + cases.append(("symlink-alias", java, aliased, manager, None)) + ancestor = root / "alternate-ancestor" + ancestor.symlink_to(root, target_is_directory=True) + alternate = dict(props) + alternate["java.home"] = str(ancestor / "jdk-21") + cases.append(("alternate-ancestor", java, alternate, manager, None)) + for label, executable, identity, sdk_executable, uid in cases: + try: + _sdk_environment.derive_android_environment( + {}, ambient, java_executable=executable, + java_properties=identity, sdkmanager_executable=sdk_executable, + java_major="21", compile_sdk="37", build_tools="36.0.0", + uid=uid, + ) + except _sdk_environment.SdkEnvironmentError: + continue + problems.append(f"SDK environment negative {label!r} was accepted") + return problems + _ownership_rule_selftests() + + +def _ownership_rule_selftests() -> list[str]: + """Exercise the root trust rule on stated (uid, mode) pairs. + + Every case is a literal, so the result is a property of the rule and not of + the uid the suite runs as or the umask that created a temporary directory. + """ + problems: list[str] = [] + trusted = 1000 + accepted = ( + ("current-user-0755", 1000, 0o755), + ("root-owned-0755", 0, 0o755), + ("current-user-0700", 1000, 0o700), + ("root-owned-0555", 0, 0o555), + ) + rejected = ( + ("unowned-uid", 1001, 0o755), + ("group-writable", 1000, 0o775), + ("world-writable", 1000, 0o757), + ("root-owned-group-writable", 0, 0o775), + ("root-owned-world-writable", 0, 0o777), + ("unowned-and-writable", 4242, 0o777), + ) + for label, uid, mode in accepted: + problem = _sdk_environment.ownership_problem( + _sdk_environment.OwnershipFacts(uid, mode), trusted_uid=trusted, + ) + if problem is not None: + problems.append(f"ownership rule rejected trusted {label!r}: {problem}") + for label, uid, mode in rejected: + if _sdk_environment.ownership_problem( + _sdk_environment.OwnershipFacts(uid, mode), trusted_uid=trusted, + ) is None: + problems.append(f"ownership rule accepted untrusted {label!r}") + # The ephemeral model stops reading mode bits and changes nothing else: + # ownership is still enforced, and an unrecognised model fails closed rather + # than falling through to the permissive branch. + ephemeral = _sdk_environment.EPHEMERAL_SINGLE_TENANT + for label, uid, mode in ( + ("group-writable", 1000, 0o775), ("world-writable", 1000, 0o777), + ("root-owned-world-writable", 0, 0o777), ("current-user-0700", 1000, 0o700), + ): + problem = _sdk_environment.ownership_problem( + _sdk_environment.OwnershipFacts(uid, mode), trusted_uid=trusted, + trust_model=ephemeral, + ) + if problem is not None: + problems.append(f"ephemeral ownership rule rejected {label!r}: {problem}") + for label, uid, mode in (("unowned-uid", 1001, 0o755), ("unowned-and-writable", 4242, 0o777)): + if _sdk_environment.ownership_problem( + _sdk_environment.OwnershipFacts(uid, mode), trusted_uid=trusted, + trust_model=ephemeral, + ) is None: + problems.append(f"ephemeral ownership rule accepted {label!r}") + for bogus in ("", "exclusive", "ephemeral", "EXCLUSIVE-FILESYSTEM", "trusted"): + if _sdk_environment.ownership_problem( + _sdk_environment.OwnershipFacts(1000, 0o777), trusted_uid=trusted, + trust_model=bogus, + ) is None: + problems.append(f"unknown root trust model {bogus!r} was accepted") + return problems + + +def check() -> list[str]: + return _contract_problems(require_generated=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--receipt", choices=["flutter", "android", "qt"]) + parser.add_argument("--static", action="store_true") + args = parser.parse_args() + spec = strict_load(SPEC_PATH)["fixtures"] + if args.receipt: + try: + receipt = json.loads(os.environ["SDK_RUNTIME_EVIDENCE"]) + except (KeyError, json.JSONDecodeError) as exc: + print(f"sdk-runtime-evidence: invalid/missing JSON: {exc}") + return 1 + problems = _receipt_problems(args.receipt, receipt, spec[args.receipt]) + else: + problems = _contract_problems(require_generated=not args.static) + if problems: + print("check_sdk_runtime_fixtures: FAIL") + for problem in problems: + print(f" - {problem}") + return 1 + print("check_sdk_runtime_fixtures: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_android_fixture_provenance.py b/scripts/generate_android_fixture_provenance.py new file mode 100644 index 0000000..601c5be --- /dev/null +++ b/scripts/generate_android_fixture_provenance.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Regenerate Android fixture locks and verification closure from exact build. + +Emits only facts a conforming toolchain reproduces: the dependency locks, the +Gradle dependency-verification closure, the task graph the exact default command +produced, and the pins the generator enforced. Observed host identity -- JDK and +SDK paths, patch versions, vendors -- is validated and then deliberately +discarded, because a committed artifact that records it stops being reproducible +the moment any of it moves. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +from ci_workflows_tools._gradle_lockfile import GradleLockfileError, parse_gradle_95_lockfile +from ci_workflows_tools._sdk_environment import ( + EPHEMERAL_SINGLE_TENANT, + EVIDENCE_NAME, + JvmIdentity, + derive_android_environment, + validate_jvm_identity, +) +from ci_workflows_tools._strict_yaml import strict_load +from ci_workflows_tools.check_python_execution_contract import clean_environment + +REPO_ROOT = Path(__file__).resolve().parent.parent +SOURCE = REPO_ROOT / "tests" / "fixtures" / "android" +SPEC = REPO_ROOT / "tests" / "fixtures" / "sdk-runtime-spec.yml" +METADATA = Path("gradle/verification-metadata.xml") +RECEIPT = Path("gradle/provenance-manifest.json") + +# Read from the spec rather than repeated here. These two digests were written +# out a second time in this file, so the wrapper contract the generator enforced +# and the one `check_sdk_runtime_fixtures.py` enforces were free to disagree -- +# and a bump would have had to be remembered in both. +_TOOLCHAIN = strict_load(SPEC)["fixtures"]["android"]["toolchain"] +WRAPPER_SHA256 = str(_TOOLCHAIN["gradle_wrapper_jar_sha256"]) +DIST_SHA256 = str(_TOOLCHAIN["gradle_distribution_sha256"]) +GRADLE_VERSION = str(_TOOLCHAIN["gradle_version"]) +JAVA_MAJOR = str(_TOOLCHAIN["java_version_input"]) +COMPILE_SDK = str(_TOOLCHAIN["compile_sdk"]) +BUILD_TOOLS = str(_TOOLCHAIN["build_tools"]) +GENERATION_ARGS = [ + "./gradlew", "build", + "--write-verification-metadata", "sha256", + "--write-locks", + "--dependency-verification", "strict", + "--no-build-cache", + "--no-configuration-cache", + "--console", "plain", +] +STRICT_ARGS = [ + "./gradlew", "build", + "--dependency-verification", "strict", + "--no-build-cache", + "--no-configuration-cache", + "--console", "plain", +] +IGNORED = {".gradle", "build", ".DS_Store"} + +# GitHub-hosted runners install the JDK into the hosted tool cache at mode 0777, +# so the strict root rule refuses every hosted runner -- and a hosted runner is +# exactly where this closure should be produced, since it is where the fixture +# lane runs. Ownership is still enforced; only the mode bits are read as +# uninformative, because the VM is single-tenant and destroyed with the job, so +# there is no other principal for them to name. +ROOT_TRUST_MODEL = EPHEMERAL_SINGLE_TENANT +NS = {"v": "https://schema.gradle.org/dependency-verification"} + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _ignore(_: str, names: list[str]) -> set[str]: + return {name for name in names if name in IGNORED} + + +def _copy_pristine(destination: Path) -> None: + shutil.copytree(SOURCE, destination, symlinks=False, ignore=_ignore) + for path in [destination / METADATA, destination / RECEIPT, *destination.rglob("*.lockfile")]: + if path.exists(): + path.unlink() + + +def _environment(home: Path) -> dict[str, str]: + clean = clean_environment({"CI": "true", "GRADLE_USER_HOME": str(home)}) + java = shutil.which("java", path=clean.get("PATH")) + sdkmanager = shutil.which("sdkmanager", path=clean.get("PATH")) + if not java or not sdkmanager: + raise RuntimeError("clean PATH must resolve java and sdkmanager") + properties = _java_properties(Path(java), clean) + return derive_android_environment( + clean, os.environ, java_executable=Path(java), java_properties=properties, + sdkmanager_executable=Path(sdkmanager), java_major=JAVA_MAJOR, + compile_sdk=COMPILE_SDK, build_tools=BUILD_TOOLS, + trust_model=ROOT_TRUST_MODEL, + ) + + +def _java_properties(executable: Path, environment: dict[str, str]) -> dict[str, str]: + output = subprocess.run( + [str(executable), "-XshowSettings:properties", "-version"], + env=environment, text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, check=True, + ).stdout + return dict(re.findall( + r"^\s+(java\.(?:home|runtime\.version|vendor|version|vm\.name)) = (.+)$", + output, re.MULTILINE, + )) + + +def _run(args: list[str], cwd: Path, home: Path) -> str: + result = subprocess.run( + args, cwd=cwd, env=_environment(home), text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, + ) + if result.returncode != 0: + sys.stderr.write(result.stdout) + raise RuntimeError(f"command failed ({result.returncode}): {' '.join(args)}") + return result.stdout + + +def _wrapper_contract(root: Path) -> None: + jar = root / "gradle/wrapper/gradle-wrapper.jar" + properties = (root / "gradle/wrapper/gradle-wrapper.properties").read_text( + encoding="utf-8" + ) + if sha256(jar) != WRAPPER_SHA256: + raise RuntimeError("Gradle wrapper JAR checksum mismatch") + if f"gradle-{GRADLE_VERSION}-bin.zip" not in properties: + raise RuntimeError( + f"Gradle wrapper does not select exact {GRADLE_VERSION} binary distribution") + if f"distributionSha256Sum={DIST_SHA256}" not in properties: + raise RuntimeError("Gradle distribution checksum mismatch") + + +def _artifacts(metadata: Path) -> list[dict[str, str]]: + root = ET.parse(metadata).getroot() + configuration = root.find("v:configuration", NS) + if configuration is None \ + or configuration.findtext("v:verify-metadata", namespaces=NS) != "true" \ + or configuration.findtext("v:verify-signatures", namespaces=NS) != "false": + raise RuntimeError("verification metadata must verify metadata with SHA-256") + if configuration.find("v:trusted-artifacts", NS) is not None: + raise RuntimeError("verification metadata may not trust wildcard artifacts") + rows: list[dict[str, str]] = [] + seen: set[tuple[str, str, str, str]] = set() + components = root.find("v:components", NS) + if components is None: + raise RuntimeError("verification metadata has no components") + for component in components.findall("v:component", NS): + group = component.attrib.get("group", "") + name = component.attrib.get("name", "") + version = component.attrib.get("version", "") + for artifact in component.findall("v:artifact", NS): + filename = artifact.attrib.get("name", "") + key = (group, name, version, filename) + hashes = artifact.findall("v:sha256", NS) + if not all(key) or key in seen or len(hashes) != 1: + raise RuntimeError(f"invalid/duplicate verification artifact: {key}") + digest = hashes[0].attrib.get("value", "") + if not re.fullmatch(r"[0-9a-f]{64}", digest): + raise RuntimeError(f"invalid SHA-256 for verification artifact: {key}") + if any(child.tag != f"{{{NS['v']}}}sha256" for child in artifact): + raise RuntimeError(f"non-SHA256 verification method for artifact: {key}") + seen.add(key) + rows.append({ + "artifact": filename, "group": group, "name": name, + "sha256": digest, "version": version, + }) + rows.sort(key=lambda row: ( + row["group"], row["name"], row["version"], row["artifact"] + )) + if not rows: + raise RuntimeError("verification artifact closure is empty") + return rows + + +def _lock_receipts(root: Path) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for path in sorted(root.rglob("*.lockfile")): + relative = path.relative_to(root).as_posix() + try: + parsed = parse_gradle_95_lockfile(path, source=relative) + except GradleLockfileError as exc: + raise RuntimeError(str(exc)) from exc + rows.append({ + "entries": list(parsed.entries), + "path": relative, + "resolved_configurations": list(parsed.resolved_configurations), + "sha256": sha256(path), + }) + if not rows or not any(row["path"] == "app/gradle.lockfile" for row in rows): + raise RuntimeError("exact build did not create the Android app lockfile") + return rows + + +def _task_graph(output: str) -> list[str]: + tasks: list[str] = [] + for line in output.splitlines(): + match = re.match(r"> Task (:\S+)", line) + if match and match.group(1) not in tasks: + tasks.append(match.group(1)) + required = {":app:assembleDebug", ":app:testDebugUnitTest", ":app:lintDebug", ":app:build"} + missing = sorted(required - set(tasks)) + if missing: + raise RuntimeError(f"exact build task graph is incomplete: missing {missing}") + # Sorted, not in execution order. Gradle schedules independent tasks + # concurrently, so two runs of the identical build emit the same set in a + # different sequence; committing the sequence made the reproduction lane + # report drift for a build that had not changed. The set is what the + # evidence is about -- which tasks the default command really ran. + return sorted(tasks) + + +def _java_identity(executable: Path | str, home: Path) -> dict[str, str]: + environment = _environment(home) + resolved = shutil.which(str(executable), path=environment.get("PATH")) \ + if not Path(executable).is_absolute() else str(executable) + if not resolved: + raise RuntimeError(f"cannot resolve Java executable {executable}") + properties = _java_properties(Path(resolved), environment) + required = {"java.home", "java.runtime.version", "java.vendor", "java.version", "java.vm.name"} + if set(properties) != required: + raise RuntimeError(f"cannot parse complete Java identity: {sorted(properties)}") + return properties + + +def _versions(root: Path, home: Path) -> dict[str, str]: + gradle = _run(["./gradlew", "--version", "--console", "plain"], root, home) + gradle_match = re.search(r"^Gradle (\S+)$", gradle, re.MULTILINE) + launcher_match = re.search(r"^Launcher JVM:\s+(.+)$", gradle, re.MULTILINE) + daemon_match = re.search(r"^Daemon JVM:\s+(.+)$", gradle, re.MULTILINE) + if not launcher_match or not daemon_match: + raise RuntimeError("Gradle --version omitted launcher or daemon JVM identity") + gradle_version = gradle_match.group(1) if gradle_match else "" + launcher_raw = launcher_match.group(1).strip() + daemon_raw = daemon_match.group(1).strip() + daemon_home = daemon_raw.split(" (", 1)[0] + java = _java_identity("java", home) + daemon = _java_identity(Path(daemon_home) / "bin/java", home) + launcher_identity = re.fullmatch(r"(\S+) \((.+)\)", launcher_raw) + if not launcher_identity: + raise RuntimeError("Gradle launcher JVM identity is unparseable") + launcher_version, launcher_details = launcher_identity.groups() + if gradle_version != GRADLE_VERSION or not java["java.version"].startswith(JAVA_MAJOR + "."): + raise RuntimeError( + f"generator requires Gradle {GRADLE_VERSION} and JDK {JAVA_MAJOR}, got " + f"{gradle_version}/{java['java.version']}" + ) + launcher_vendor = launcher_details.removesuffix(" " + java["java.runtime.version"]) + validate_jvm_identity(JvmIdentity( + requested_major=JAVA_MAJOR, java_home=java["java.home"], + java_version=java["java.version"], java_runtime_version=java["java.runtime.version"], + java_vendor=java["java.vendor"], java_vm_name=java["java.vm.name"], + launcher_version=launcher_version, + launcher_runtime_version=java["java.runtime.version"], + launcher_vendor=launcher_vendor, launcher_vm_name=java["java.vm.name"], + daemon_home=daemon["java.home"], daemon_version=daemon["java.version"], + daemon_runtime_version=daemon["java.runtime.version"], + daemon_vendor=daemon["java.vendor"], daemon_vm_name=daemon["java.vm.name"], + )) + # Enforced here, recorded nowhere. Everything above is an *observation of + # this machine*: absolute JDK and Android SDK paths, a JDK patch version, a + # vendor string. Writing those into a committed file would make the fixture's + # provenance a claim about whichever host generated it, and would make + # `--check` fail every time a runner image bumps its JDK patch -- a + # calendar-driven external fact wearing the costume of a tree property. + # Environment identity belongs in the per-run receipt the reusable emits, + # where it describes the run that actually happened. What is committed is + # only what any conforming toolchain reproduces byte for byte. + json.loads(_environment(home)[EVIDENCE_NAME]) + return { + "gradle_version": gradle_version, + "java_version_input": JAVA_MAJOR, + } + + +def _receipt(root: Path, output: str, identities: dict[str, str]) -> bytes: + metadata = root / METADATA + artifacts = _artifacts(metadata) + locks = _lock_receipts(root) + configurations = sorted({ + configuration + for lock in locks + for configuration in lock["resolved_configurations"] + }) + payload = { + "artifacts": artifacts, + "default_command": "./gradlew build", + "distribution_sha256": DIST_SHA256, + "generation_arguments": GENERATION_ARGS, + **identities, + "locks": locks, + "resolution_scope": "fresh exact build plus Gradle verification bootstrap resolvable configurations", + "resolved_locked_configurations": configurations, + "schema_version": 1, + "task_graph": _task_graph(output), + "verification_metadata_sha256": sha256(metadata), + "wrapper_jar_sha256": WRAPPER_SHA256, + } + return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def generate() -> tuple[dict[Path, bytes], str]: + with tempfile.TemporaryDirectory(prefix="ci-workflows-android-provenance-") as temp: + temp_root = Path(temp) + first = temp_root / "generate" / "android" + first.parent.mkdir() + _copy_pristine(first) + _wrapper_contract(first) + first_home = temp_root / "gradle-home-generate" + identities = _versions(first, first_home) + output = _run(GENERATION_ARGS, first, first_home) + receipt = _receipt(first, output, identities) + + generated = { + METADATA: (first / METADATA).read_bytes(), + RECEIPT: receipt, + } + for lock in sorted(first.rglob("*.lockfile")): + generated[lock.relative_to(first)] = lock.read_bytes() + + second = temp_root / "verify" / "android" + second.parent.mkdir() + _copy_pristine(second) + for relative, raw in generated.items(): + target = second / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(raw) + _wrapper_contract(second) + strict_output = _run(STRICT_ARGS, second, temp_root / "gradle-home-verify") + if "BUILD SUCCESSFUL" not in strict_output: + raise RuntimeError("strict fresh-home default build did not report success") + return generated, output + + +def _current_generated() -> dict[Path, bytes]: + paths = [METADATA, RECEIPT, *sorted( + path.relative_to(SOURCE) for path in SOURCE.rglob("*.lockfile") + )] + return {relative: (SOURCE / relative).read_bytes() for relative in paths if (SOURCE / relative).is_file()} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + try: + generated, output = generate() + except (OSError, RuntimeError, subprocess.SubprocessError, ET.ParseError) as exc: + print(f"android-provenance: {exc}", file=sys.stderr) + return 1 + if args.check: + current = _current_generated() + if current != generated: + missing = sorted(str(path) for path in generated.keys() - current.keys()) + extra = sorted(str(path) for path in current.keys() - generated.keys()) + changed = sorted( + str(path) for path in generated.keys() & current.keys() + if generated[path] != current[path] + ) + print( + f"android-provenance: generated drift: missing={missing} " + f"extra={extra} changed={changed}", file=sys.stderr, + ) + return 1 + print("android-provenance: reproducible exact-build closure OK") + return 0 + current = _current_generated() + for stale in current.keys() - generated.keys(): + (SOURCE / stale).unlink() + for relative, raw in generated.items(): + target = SOURCE / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(raw) + print("android-provenance: wrote exact-build metadata, locks, and receipt") + print(output, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_sdk_runtime_manifest.py b/scripts/generate_sdk_runtime_manifest.py new file mode 100644 index 0000000..a4eabfa --- /dev/null +++ b/scripts/generate_sdk_runtime_manifest.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Generate the byte-provenance manifest for the three SDK fixtures.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path + +from ci_workflows_tools._strict_yaml import strict_load + +ROOT = Path(__file__).resolve().parent.parent +SPEC = ROOT / "tests/fixtures/sdk-runtime-spec.yml" +OUTPUT = ROOT / "tests/fixtures/sdk-runtime-manifest.json" +FIXTURE_ROOTS = ("tests/fixtures/flutter", "tests/fixtures/android", "tests/fixtures/qt") +IGNORED = {".dart_tool", ".gradle", "build"} + + +def source_paths() -> list[Path]: + paths = [] + for relative in FIXTURE_ROOTS: + for path in (ROOT / relative).rglob("*"): + if any(part in IGNORED for part in path.relative_to(ROOT).parts): + continue + if path.is_symlink() or (path.exists() and not path.is_file() and not path.is_dir()): + raise ValueError(f"fixture entry must be regular: {path.relative_to(ROOT)}") + if path.is_file(): + paths.append(path) + return sorted(paths, key=lambda path: path.relative_to(ROOT).as_posix()) + + +def render() -> bytes: + spec_raw = SPEC.read_bytes() + strict_load(SPEC) + rows = [] + for path in source_paths(): + relative = path.relative_to(ROOT).as_posix() + raw = path.read_bytes() + kind = "binary" if relative.endswith("gradle-wrapper.jar") else "text" + if kind == "text": + raw.decode("utf-8") + if b"\r" in raw or not raw.endswith(b"\n") or raw.endswith(b"\n\n"): + raise ValueError(f"{relative}: text must be LF-only with exactly one terminal LF") + rows.append({"kind": kind, "path": relative, "sha256": hashlib.sha256(raw).hexdigest(), "size": len(raw)}) + return (json.dumps({ + "files": rows, + "schema_version": 1, + "spec_sha256": hashlib.sha256(spec_raw).hexdigest(), + }, indent=2, sort_keys=True) + "\n").encode() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + try: + expected = render() + except (OSError, UnicodeError, ValueError) as exc: + print(f"sdk-runtime-manifest: {exc}", file=sys.stderr) + return 1 + if args.check: + if not OUTPUT.is_file() or OUTPUT.read_bytes() != expected: + print("sdk-runtime-manifest: stale generated data", file=sys.stderr) + return 1 + print("sdk-runtime-manifest: OK") + return 0 + OUTPUT.write_bytes(expected) + print(f"wrote {OUTPUT.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_all.py b/scripts/validate_all.py index 578e186..44df1b8 100644 --- a/scripts/validate_all.py +++ b/scripts/validate_all.py @@ -46,6 +46,7 @@ check_actionlint_contract, check_benchmark_contract, check_docs_links, + check_flutter_pin, check_documented_commands, check_examples, check_gate_contract, @@ -68,6 +69,7 @@ check_runtime_requirements, check_scorecard_evidence_contract, check_secret_scan_contract, + check_sdk_runtime_fixtures, check_side_effect_fixture_contract, check_skills, check_tool_pinning, @@ -114,6 +116,7 @@ ("runtime-requirements", check_runtime_requirements.check), ("runner-routing", check_runner_routing.check), ("secret-scan-contract", check_secret_scan_contract.check), + ("sdk-runtime-fixtures", check_sdk_runtime_fixtures.check), ("scorecard-evidence-contract", check_scorecard_evidence_contract.check), ("evidence-orchestration", compile_evidence_plan.check), ("runtime-evidence-summary", render_runtime_evidence.check), @@ -143,6 +146,7 @@ ("runtime-coverage-calendar", validate_runtime_coverage.check), ("release-ledger-tags", check_release_ledger.check_tags), ("docs-links", check_docs_links.check), + ("flutter-pin", check_flutter_pin.check), ] diff --git a/scripts/validate_runtime_coverage.py b/scripts/validate_runtime_coverage.py index 96a875d..3dae28e 100644 --- a/scripts/validate_runtime_coverage.py +++ b/scripts/validate_runtime_coverage.py @@ -43,6 +43,13 @@ "event-context", "permission-side-effect", "heavy-toolchain", "specialized-harness", "destructive-release", "external-secret", "licensing", "real-host", "external-authority", + # A dependency the repository's own supply-chain policy forbids. Distinct + # from `heavy-toolchain`, which says provisioning is expensive, and from + # `external-authority`, which says someone else must act: here the workflow + # is refused before it starts because a third-party action names nested + # actions by tag and this repository requires full-length commit SHAs. The + # fix is ours to make, and no amount of fixture work reaches it. + "dependency-policy-conflict", } OBJECTIVE_WAIVER_TYPES = {"external-secret", "licensing", "real-host"} # For these tiers, absence of evidence is itself a failure. `unverified` — the diff --git a/tests/fixtures/android/.gitignore b/tests/fixtures/android/.gitignore new file mode 100644 index 0000000..94d420a --- /dev/null +++ b/tests/fixtures/android/.gitignore @@ -0,0 +1,2 @@ +/.gradle/ +/**/build/ diff --git a/tests/fixtures/android/app/build.gradle.kts b/tests/fixtures/android/app/build.gradle.kts new file mode 100644 index 0000000..ab6c297 --- /dev/null +++ b/tests/fixtures/android/app/build.gradle.kts @@ -0,0 +1,28 @@ +import org.gradle.api.artifacts.dsl.LockMode + +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace = "com.nddev.ci.fixture" + compileSdk = libs.versions.compile.sdk.get().toInt() + buildToolsVersion = libs.versions.build.tools.get() + + defaultConfig { + applicationId = "com.nddev.ci.fixture" + minSdk = 23 + targetSdk = 37 + versionCode = 1 + versionName = "1.0" + } +} + +dependencies { + testImplementation(libs.junit) +} + +dependencyLocking { + lockAllConfigurations() + lockMode.set(LockMode.STRICT) +} diff --git a/tests/fixtures/android/app/gradle.lockfile b/tests/fixtures/android/app/gradle.lockfile new file mode 100644 index 0000000..18b1c4c --- /dev/null +++ b/tests/fixtures/android/app/gradle.lockfile @@ -0,0 +1,191 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.android.tools.analytics-library:protos:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.analytics-library:shared:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.analytics-library:tracker:32.3.1=androidLintTool +com.android.tools.build:aapt2-proto:9.3.1-15703166=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.build:builder-model:9.3.1=androidLintTool +com.android.tools.build:manifest-merger:32.3.1=androidLintTool +com.android.tools.ddms:ddmlib:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.android.tools.emulator:proto:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control +com.android.tools.external.com-intellij:intellij-core:32.3.1=androidLintTool +com.android.tools.external.com-intellij:kotlin-compiler:32.3.1=androidLintTool +com.android.tools.external.org-jetbrains:uast:32.3.1=androidLintTool +com.android.tools.layoutlib:layoutlib-api:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.lint:lint-api:32.3.1=androidLintTool +com.android.tools.lint:lint-checks:32.3.1=androidLintTool +com.android.tools.lint:lint-gradle:32.3.1=androidLintTool +com.android.tools.lint:lint-model:32.3.1=androidLintTool +com.android.tools.lint:lint-typedef-remover:32.3.1=androidLintTool +com.android.tools.lint:lint:32.3.1=androidLintTool +com.android.tools.utp:android-device-provider-ddmlib-proto:32.3.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-gradle-work-action +com.android.tools.utp:android-device-provider-ddmlib:32.3.1=unified-test-platform-android-device-provider-ddmlib +com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-additional-test-output:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output +com.android.tools.utp:android-test-plugin-host-apk-installer-proto:32.3.1=unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-apk-installer:32.3.1=unified-test-platform-android-test-plugin-host-apk-installer +com.android.tools.utp:android-test-plugin-host-coverage-proto:32.3.1=unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-coverage:32.3.1=unified-test-platform-android-test-plugin-host-coverage +com.android.tools.utp:android-test-plugin-host-device-info-proto:32.3.1=unified-test-platform-android-test-plugin-host-device-info +com.android.tools.utp:android-test-plugin-host-device-info:32.3.1=unified-test-platform-android-test-plugin-host-device-info +com.android.tools.utp:android-test-plugin-host-emulator-control-proto:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-emulator-control:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control +com.android.tools.utp:android-test-plugin-host-logcat-proto:32.3.1=unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-host-logcat:32.3.1=unified-test-platform-android-test-plugin-host-logcat +com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:32.3.1=unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.android.tools.utp:android-test-plugin-result-listener-gradle:32.3.1=unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.utp:gradle-work-action:32.3.1=unified-test-platform-gradle-work-action +com.android.tools.utp:utp-common:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat +com.android.tools:annotations:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.android.tools:common:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.android.tools:dvlib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools:play-sdk-proto:32.3.1=androidLintTool +com.android.tools:repository:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools:sdk-common:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools:sdklib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.android:annotations:4.1.1.4=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-core +com.google.api.grpc:proto-google-common-protos:2.17.0=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core +com.google.api.grpc:proto-google-common-protos:2.48.0=unified-test-platform-android-test-plugin-host-emulator-control +com.google.auto.service:auto-service-annotations:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto.service:auto-service:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto:auto-common:1.2.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.code.findbugs:jsr305:3.0.2=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.code.gson:gson:2.10.1=unified-test-platform-core,unified-test-platform-gradle-work-action +com.google.code.gson:gson:2.11.0=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.code.gson:gson:2.8.9=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher +com.google.crypto.tink:tink:1.18.0=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action +com.google.dagger:dagger:2.48=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action +com.google.errorprone:error_prone_annotations:2.23.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.guava:failureaccess:1.0.1=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.guava:failureaccess:1.0.2=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.guava:guava:32.0.1-jre=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.guava:guava:33.4.0-jre=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.j2objc:j2objc-annotations:2.8=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.jimfs:jimfs:1.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.protobuf:protobuf-java-util:3.22.3=unified-test-platform-core +com.google.protobuf:protobuf-java-util:4.28.3=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.protobuf:protobuf-java:3.25.5=androidLintTool +com.google.protobuf:protobuf-java:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.protobuf:protobuf-kotlin:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.testing.platform:android-device-provider-local:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle +com.google.testing.platform:android-driver-instrumentation:0.0.9-alpha04=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control +com.google.testing.platform:android-test-plugin:0.0.9-alpha04=unified-test-platform-android-test-plugin +com.google.testing.platform:core-proto:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +com.google.testing.platform:core:0.0.9-alpha04=unified-test-platform-core +com.google.testing.platform:launcher:0.0.9-alpha04=unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +commons-codec:commons-codec:1.17.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +commons-io:commons-io:2.16.1=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +commons-logging:commons-logging:1.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +io.grpc:grpc-api:1.57.2=unified-test-platform-core +io.grpc:grpc-api:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-context:1.57.2=unified-test-platform-core +io.grpc:grpc-context:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-core:1.57.2=unified-test-platform-core +io.grpc:grpc-core:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-netty:1.57.2=unified-test-platform-core +io.grpc:grpc-netty:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-protobuf-lite:1.57.2=unified-test-platform-core +io.grpc:grpc-protobuf-lite:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-protobuf:1.57.2=unified-test-platform-core +io.grpc:grpc-protobuf:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-services:1.57.2=unified-test-platform-core +io.grpc:grpc-stub:1.57.2=unified-test-platform-core +io.grpc:grpc-stub:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.grpc:grpc-util:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-buffer:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-buffer:4.1.93.Final=unified-test-platform-core +io.netty:netty-codec-http2:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec-http2:4.1.93.Final=unified-test-platform-core +io.netty:netty-codec-http:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec-http:4.1.93.Final=unified-test-platform-core +io.netty:netty-codec-socks:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec-socks:4.1.93.Final=unified-test-platform-core +io.netty:netty-codec:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-codec:4.1.93.Final=unified-test-platform-core +io.netty:netty-common:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-common:4.1.93.Final=unified-test-platform-core +io.netty:netty-handler-proxy:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-handler-proxy:4.1.93.Final=unified-test-platform-core +io.netty:netty-handler:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-handler:4.1.93.Final=unified-test-platform-core +io.netty:netty-resolver:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-resolver:4.1.93.Final=unified-test-platform-core +io.netty:netty-transport-native-unix-common:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-transport-native-unix-common:4.1.93.Final=unified-test-platform-core +io.netty:netty-transport:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control +io.netty:netty-transport:4.1.93.Final=unified-test-platform-core +io.opencensus:opencensus-api:0.31.0=unified-test-platform-core +io.opencensus:opencensus-proto:0.2.0=unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +io.perfmark:perfmark-api:0.26.0=unified-test-platform-core +io.perfmark:perfmark-api:0.27.0=unified-test-platform-android-test-plugin-host-emulator-control +jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +javax.annotation:javax.annotation-api:1.3.2=unified-test-platform-android-test-plugin-host-emulator-control +javax.inject:javax.inject:1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action +junit:junit:4.13.2=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +net.java.dev.jna:jna-platform:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +net.java.dev.jna:jna:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +net.sf.kxml:kxml2:2.3.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.apache.commons:commons-compress:1.27.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.commons:commons-lang3:3.16.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.httpcomponents:httpclient:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.httpcomponents:httpcore:4.4.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.apache.httpcomponents:httpmime:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.bouncycastle:bcpkix-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.bouncycastle:bcprov-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.bouncycastle:bcutil-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.checkerframework:checker-qual:3.33.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +org.checkerframework:checker-qual:3.43.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.codehaus.groovy:groovy:3.0.22=androidLintTool +org.codehaus.mojo:animal-sniffer-annotations:1.23=unified-test-platform-core +org.codehaus.mojo:animal-sniffer-annotations:1.24=unified-test-platform-android-test-plugin-host-emulator-control +org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.glassfish.jaxb:txw2:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.hamcrest:hamcrest-core:1.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.jetbrains.kotlin:kotlin-build-tools-api:2.2.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.2.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.2.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.2.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.2.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.2.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-reflect:1.8.21=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlin:kotlin-script-runtime:2.2.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.2.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.2.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.2.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-scripting-jvm:2.2.10=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib-common:2.2.10=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.jetbrains.kotlin:kotlin-stdlib:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core +org.jetbrains.kotlin:kotlin-stdlib:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action +org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:atomicfu:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains:annotations:13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains:annotations:23.0.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jvnet.staxex:stax-ex:1.8.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle +org.ow2.asm:asm-analysis:9.9=androidLintTool +org.ow2.asm:asm-commons:9.9=androidLintTool +org.ow2.asm:asm-tree:9.9=androidLintTool +org.ow2.asm:asm:9.9=androidLintTool +empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestApiDependenciesMetadata,debugAndroidTestCompileOnlyDependenciesMetadata,debugAndroidTestImplementationDependenciesMetadata,debugAndroidTestIntransitiveDependenciesMetadata,debugAndroidTestRuntimeClasspath,debugAnnotationProcessorClasspath,debugApiDependenciesMetadata,debugCompileOnlyDependenciesMetadata,debugImplementationDependenciesMetadata,debugIntransitiveDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestApiDependenciesMetadata,debugUnitTestCompileOnlyDependenciesMetadata,debugUnitTestImplementationDependenciesMetadata,debugUnitTestIntransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseApiDependenciesMetadata,releaseCompileOnlyDependenciesMetadata,releaseImplementationDependenciesMetadata,releaseIntransitiveDependenciesMetadata,releaseReverseMetadataValues diff --git a/tests/fixtures/android/app/src/main/AndroidManifest.xml b/tests/fixtures/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..aa37d42 --- /dev/null +++ b/tests/fixtures/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tests/fixtures/android/app/src/main/kotlin/com/nddev/ci/fixture/MainActivity.kt b/tests/fixtures/android/app/src/main/kotlin/com/nddev/ci/fixture/MainActivity.kt new file mode 100644 index 0000000..8f9214e --- /dev/null +++ b/tests/fixtures/android/app/src/main/kotlin/com/nddev/ci/fixture/MainActivity.kt @@ -0,0 +1,14 @@ +package com.nddev.ci.fixture + +import android.app.Activity +import android.os.Bundle +import android.widget.TextView + +class MainActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(TextView(this).apply { text = fixtureMessage() }) + } +} + +fun fixtureMessage(): String = "ci-workflows fixture" diff --git a/tests/fixtures/android/app/src/main/res/values/styles.xml b/tests/fixtures/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..27adb98 --- /dev/null +++ b/tests/fixtures/android/app/src/main/res/values/styles.xml @@ -0,0 +1,3 @@ + +