From 4f626a51682ddd890c411996e5815357925ea1df Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Fri, 16 Oct 2026 08:34:44 -0700 Subject: [PATCH] Separate application builds from artifact attestation --- .github/actionlint.yaml | 5 - .github/workflows/android-attest.yml | 74 +++++++++ .github/workflows/android-build.yml | 157 -------------------- .github/workflows/android-signer-ci.yml | 2 +- android-signer/README.md | 46 +++++- android-signer/tests/test_trusted_policy.py | 2 +- android-signer/tests/test_workflows.py | 73 +++++++-- 7 files changed, 179 insertions(+), 180 deletions(-) create mode 100644 .github/workflows/android-attest.yml delete mode 100644 .github/workflows/android-build.yml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index b2cc216..b40d3d1 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -4,11 +4,6 @@ paths: # GitHub.com documents job.workflow_sha, but actionlint 1.7.12 predates it. # https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#job-context - 'property "workflow_sha" is not defined in object type' - .github/workflows/android-build.yml: - ignore: - # Documented GitHub.com features newer than actionlint 1.7.12. - - 'property "workflow_(ref|sha)" is not defined in object type' - - 'specifying action "\$/android-signer" in invalid format' .github/workflows/android-finalize.yml: ignore: # Documented GitHub.com features newer than actionlint 1.7.12. diff --git a/.github/workflows/android-attest.yml b/.github/workflows/android-attest.yml new file mode 100644 index 0000000..4320ac9 --- /dev/null +++ b/.github/workflows/android-attest.yml @@ -0,0 +1,74 @@ +name: Trusted Android artifact provenance +on: + workflow_call: + inputs: + profile: + description: Profile used in this run's immutable artifact names + required: true + type: string +permissions: + contents: read +jobs: + attest-unsigned: + name: Attest unsigned ${{ inputs.profile }} + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + id-token: write + attestations: write + steps: + - name: Validate artifact profile + env: + PROFILE: ${{ inputs.profile }} + run: | + python3 - <<'PYTHON' + import os, re + if not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", os.environ["PROFILE"]): + raise SystemExit("Invalid artifact profile") + PYTHON + - name: Download this attempt's immutable inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unsigned-${{ inputs.profile }}-${{ github.run_attempt }} + path: unsigned/ + - name: Download this attempt's release support files + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-support-${{ inputs.profile }}-${{ github.run_attempt }} + path: support/ + - name: Validate artifact handoff + run: | + python3 - <<'PYTHON' + from pathlib import Path + for directory, expected in ( + ("unsigned", {"app.apk", "app.aab"}), + ("support", {"mapping.txt", "native-debug-symbols.zip", "build-info.json"}), + ): + entries = list(Path(directory).rglob("*")) + files = {str(p.relative_to(directory)) for p in entries if p.is_file()} + if any(p.is_symlink() for p in entries) or files != expected: + raise SystemExit("Unexpected artifact handoff: " + directory) + PYTHON + - name: Attest original APK, AAB, and release support files + id: provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: | + unsigned/app.apk + unsigned/app.aab + support/mapping.txt + support/native-debug-symbols.zip + support/build-info.json + create-storage-record: false + - name: Preserve provenance bundle + env: + BUNDLE: ${{ steps.provenance.outputs.bundle-path }} + FLAVOR: ${{ inputs.profile }} + run: cp "$BUNDLE" "provenance-${FLAVOR}.json" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: provenance-${{ inputs.profile }}-${{ github.run_attempt }} + path: provenance-${{ inputs.profile }}.json + if-no-files-found: error diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml deleted file mode 100644 index efe0ea3..0000000 --- a/.github/workflows/android-build.yml +++ /dev/null @@ -1,157 +0,0 @@ -name: Trusted Android build and provenance -on: - workflow_call: -permissions: - contents: read -jobs: - release: - name: Release ${{ matrix.flavor }} - runs-on: ubuntu-latest - permissions: - contents: read - strategy: - matrix: - include: - - flavor: oss - task: Oss - - flavor: google - task: Google - - steps: - - name: Checkout (with history) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - fetch-tags: true - persist-credentials: false - - - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - - - name: Set up JDK - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 - with: - distribution: 'zulu' - java-version: '17' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - with: - cache-disabled: true - - - name: Delete old Android SDK cmdline-tools - run: rm -rf "$ANDROID_HOME/cmdline-tools/latest" - - - name: Setup Android SDK - uses: android-actions/setup-android@be39fa834029ff78f1a44aa3bb0819b8fc2bd8fd # v4.0.4 - with: - packages: 'cmdline-tools;latest platform-tools' - - - name: Build release APK and bundle - env: - FLAVOR_TASK: ${{ matrix.task }} - IS_PUSH: ${{ (github.event_name == 'push') }} - run: | - tasks=(":app:assemble${FLAVOR_TASK}Release") - if [[ "$IS_PUSH" == 'true' ]]; then - tasks+=(":app:bundle${FLAVOR_TASK}Release") - fi - ./gradlew --no-build-cache --no-configuration-cache --profile "${tasks[@]}" - - - name: Store Gradle profile - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: gradle-profile-release-${{ matrix.flavor }} - path: build/reports/profile/ - if-no-files-found: warn - retention-days: 14 - - - name: Prepare stable signing inputs - if: ${{ github.event_name == 'push' }} - env: - FLAVOR: ${{ matrix.flavor }} - run: | - mkdir unsigned - cp "app/build/outputs/apk/${FLAVOR}/release/app-${FLAVOR}-release-unsigned.apk" unsigned/app.apk - cp "app/build/outputs/bundle/${FLAVOR}Release/app-${FLAVOR}-release.aab" unsigned/app.aab - - - name: Store immutable unsigned inputs - if: ${{ github.event_name == 'push' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: unsigned-${{ matrix.flavor }}-${{ github.run_attempt }} - path: unsigned/ - if-no-files-found: error - - - name: Prepare release support files - if: ${{ github.event_name == 'push' }} - env: - FLAVOR: ${{ matrix.flavor }} - run: | - mkdir support - cp "app/build/outputs/native-debug-symbols/${FLAVOR}Release/native-debug-symbols.zip" support/ - cp "app/build/outputs/mapping/${FLAVOR}Release/mapping.txt" support/ - - - name: Record release metadata - if: ${{ github.event_name == 'push' }} - uses: $/android-signer - with: - command: build-info - out: support - profile: ${{ matrix.flavor }} - version-code-file: app/build/outputs/app_versioning/${{ matrix.flavor }}Release/version_code.txt - - - name: Store immutable release support files - if: ${{ github.event_name == 'push' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-support-${{ matrix.flavor }}-${{ github.run_attempt }} - path: support/ - if-no-files-found: error - - attest-unsigned: - name: Attest unsigned ${{ matrix.flavor }} - if: ${{ github.event_name == 'push' }} - needs: release - runs-on: ubuntu-latest - permissions: - contents: read - actions: read - id-token: write - attestations: write - strategy: - matrix: - flavor: [oss, google] - steps: - - name: Download this attempt's immutable inputs - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: unsigned-${{ matrix.flavor }}-${{ github.run_attempt }} - path: unsigned/ - - name: Download this attempt's release support files - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: release-support-${{ matrix.flavor }}-${{ github.run_attempt }} - path: support/ - - name: Attest original APK, AAB, and release support files - id: provenance - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 - with: - subject-path: | - unsigned/app.apk - unsigned/app.aab - support/mapping.txt - support/native-debug-symbols.zip - support/build-info.json - create-storage-record: false - - name: Preserve provenance bundle - env: - BUNDLE: ${{ steps.provenance.outputs.bundle-path }} - FLAVOR: ${{ matrix.flavor }} - run: cp "$BUNDLE" "provenance-${FLAVOR}.json" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: provenance-${{ matrix.flavor }}-${{ github.run_attempt }} - path: provenance-${{ matrix.flavor }}.json - if-no-files-found: error diff --git a/.github/workflows/android-signer-ci.yml b/.github/workflows/android-signer-ci.yml index b8927ba..10ee1bb 100644 --- a/.github/workflows/android-signer-ci.yml +++ b/.github/workflows/android-signer-ci.yml @@ -21,4 +21,4 @@ jobs: - name: Verify action contracts and adversarial publishing cases run: python3 -m unittest discover -s android-signer/tests -v - name: Validate workflow - run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 .github/workflows/android-signer-ci.yml .github/workflows/android-build.yml .github/workflows/android-finalize.yml .github/workflows/android-publish.yml + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 .github/workflows/android-signer-ci.yml .github/workflows/android-attest.yml .github/workflows/android-finalize.yml .github/workflows/android-publish.yml diff --git a/android-signer/README.md b/android-signer/README.md index d6769ed..06d4a45 100644 --- a/android-signer/README.md +++ b/android-signer/README.md @@ -5,7 +5,7 @@ signer. Private Android keys stay on the signing appliance. This action runs Python standard-library code and pinned Android reference tools; it does not build Android applications or hold their signing keys. -For direct composite use, use `connectbot/actions/android-signer@FULL_COMMIT_SHA`. Keep the thin build, +For legacy content-pin configurations, use `connectbot/actions/android-signer@FULL_COMMIT_SHA`. Keep the thin build, attestation, finalization, and publishing workflows in the consuming repository, with separate hosted jobs and minimal permissions. Keep its public signing policy there too. Moving this composite action does not change reusable workflow @@ -55,10 +55,46 @@ Android payload preservation, publishing recovery, and argument injection. ## Trusted reusable workflows -Call `connectbot/actions/.github/workflows/android-build.yml@android-signer-v1` -for the fixed Android release build and separate provenance jobs. It accepts no -caller commands, source overrides, or signing digests. Build and attest jobs are -fresh hosted jobs; Gradle has no provenance credentials. +Keep application builds in the consuming repository: Java/SDK setup, Gradle tasks, +flavor matrices, output paths, and release metadata belong beside the application. +Build jobs must have only `contents: read` permission and no signing secrets or +provenance credentials. Use fresh hosted runners and disable shared build caches. +The `build-info` composite command can create release metadata alongside the build. + +After the build succeeds, call +`connectbot/actions/.github/workflows/android-attest.yml@android-signer-v1` once +per profile: + +```yaml +attest-unsigned: + needs: release + if: ${{ github.event_name == 'push' }} + strategy: + matrix: + flavor: [oss, google] + uses: connectbot/actions/.github/workflows/android-attest.yml@android-signer-v1 + permissions: + contents: read + actions: read + id-token: write + attestations: write + with: + profile: ${{ matrix.flavor }} +``` + +The build must upload `unsigned-PROFILE-ATTEMPT` containing `app.apk` and `app.aab`, +and `release-support-PROFILE-ATTEMPT` containing `mapping.txt`, +`native-debug-symbols.zip`, and `build-info.json`, with flat paths. `ATTEMPT` is +`${{ github.run_attempt }}`. This is a stable handoff contract, independent of +Gradle task names, project layout, Java versions, and original output filenames. +A new profile additionally needs independently provisioned signing policy/keys. + +The shared attestor accepts only a profile label. It derives repository, run and +attempt from GitHub, downloads that run's immutable artifacts, checks their file +sets, and computes the subject digests on a fresh hosted runner. It accepts no +caller commands, artifact IDs, source overrides, or signing digests and never +checks out or executes application code. Provenance authenticates the source run +and output bytes; it does not certify the application's build logic as safe. The finalizer and publishers are `android-finalize.yml` and `android-publish.yml` at the same protected branch. They load the public policy from immutable diff --git a/android-signer/tests/test_trusted_policy.py b/android-signer/tests/test_trusted_policy.py index c0af4cd..25b1a5b 100644 --- a/android-signer/tests/test_trusted_policy.py +++ b/android-signer/tests/test_trusted_policy.py @@ -12,7 +12,7 @@ p = fixtures.p -def identity(workflow="android-build.yml"): +def identity(workflow="android-attest.yml"): return {"repository": "connectbot/actions", "repository_id": 1369420724, "workflow": ".github/workflows/" + workflow, "refs": ["refs/heads/android-signer-v1"], "authorization": "workflow-identity", "sha256": [], "helpers": {}} diff --git a/android-signer/tests/test_workflows.py b/android-signer/tests/test_workflows.py index d5cdc8f..1b08d4a 100644 --- a/android-signer/tests/test_workflows.py +++ b/android-signer/tests/test_workflows.py @@ -1,6 +1,11 @@ #!/usr/bin/env python3 # Copyright 2026 Kenny Root from pathlib import Path +import os +import subprocess +import sys +import tempfile +import textwrap import unittest ROOT = Path(__file__).resolve().parents[2] @@ -18,17 +23,63 @@ def test_publishers_verify_before_google_authentication_and_only_target_alpha(se self.assertNotIn("id-token: write", workflow.split(" play:")[0]) self.assertNotIn("actions/checkout@", workflow) - def test_fixed_build_separates_gradle_from_attestation_credentials(self): - workflow = (ROOT / ".github/workflows/android-build.yml").read_text() - build, attest = workflow.split(" attest-unsigned:\n") - self.assertNotIn(" inputs:", build) - self.assertNotIn("id-token: write", build) - self.assertNotIn("attestations: write", build) - self.assertNotIn("actions/checkout@", attest) - self.assertNotIn("gradlew", attest) - self.assertIn("support/build-info.json", attest) - self.assertIn("name: unsigned-${{ matrix.flavor }}-${{ github.run_attempt }}", build) - self.assertIn("name: release-support-${{ matrix.flavor }}-${{ github.run_attempt }}", build) + def attestor(self): + return (ROOT / ".github/workflows/android-attest.yml").read_text() + + def python_step(self, name): + step = self.attestor().split(" - name: " + name + "\n", 1)[1] + code = step.split("<<'PYTHON'\n", 1)[1].split(" PYTHON\n", 1)[0] + return textwrap.dedent(code) + + def test_attestor_only_accepts_profile_and_uses_platform_run_identity(self): + workflow = self.attestor() + interface, job = workflow.split("jobs:\n", 1) + self.assertEqual(interface.count(" type: string"), 1) + self.assertIn(" profile:", interface) + for forbidden in ("actions/checkout@", "gradlew", "setup-java@", "setup-android@", + "setup-gradle@", "secrets:", "run-id:", "repository:", + "artifact-ids:", "pattern:", "merge-multiple:", "continue-on-error:"): + self.assertNotIn(forbidden, workflow) + self.assertIn("runs-on: ubuntu-latest", job) + self.assertIn("github.event_name == 'push'", job) + for artifact in ("unsigned", "release-support", "provenance"): + self.assertIn("name: " + artifact + "-${{ inputs.profile }}-${{ github.run_attempt }}", job) + self.assertIn("support/build-info.json", job) + self.assertLess(job.index("Validate artifact handoff"), job.index("uses: actions/attest@")) + self.assertFalse((ROOT / ".github/workflows/android-build.yml").exists()) + + def test_profile_rejects_paths_globs_and_shell_expressions(self): + code = self.python_step("Validate artifact profile") + for profile in ("oss", "google", "another-profile", "../oss", "*", "oss/extra", + "$(touch injected)", "oss\nextra", "", "a" * 33): + with self.subTest(profile=profile): + result = subprocess.run([sys.executable, "-c", code], + env={**os.environ, "PROFILE": profile}, capture_output=True) + self.assertEqual(result.returncode == 0, profile in ("oss", "google", "another-profile")) + + def test_handoff_rejects_missing_extra_and_symlinked_files(self): + code = self.python_step("Validate artifact handoff") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for directory, names in (("unsigned", ("app.apk", "app.aab")), + ("support", ("mapping.txt", "native-debug-symbols.zip", "build-info.json"))): + (root / directory).mkdir() + for name in names: + (root / directory / name).write_bytes(b"untrusted build output") + def verify(): + return subprocess.run([sys.executable, "-c", code], cwd=root, capture_output=True).returncode + self.assertEqual(verify(), 0) + artifact = root / "unsigned/app.apk" + artifact.unlink() + self.assertNotEqual(verify(), 0) + artifact.write_bytes(b"apk") + extra = root / "support/execute.py" + extra.write_text("raise SystemExit('must never run')") + self.assertNotEqual(verify(), 0) + extra.unlink() + artifact.unlink() + artifact.symlink_to(root / "unsigned/app.aab") + self.assertNotEqual(verify(), 0) if __name__ == "__main__":