From bb820cc45d6c68c2575331f302b8cf0766dd3640 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:54:45 +0000 Subject: [PATCH 1/3] release: attach proxy artifacts to Maple releases --- .agents/skills/release-maple/SKILL.md | 11 +- .github/workflows/release-gates-tests.yml | 12 ++ .github/workflows/release.yml | 143 ++++++++++++++++++++- README.md | 3 +- flake.nix | 4 + proxy/README.md | 13 ++ proxy/src/config.rs | 1 + scripts/ci/proxy-release.sh | 115 +++++++++++++++++ scripts/ci/release-verification-guide.sh | 12 +- scripts/ci/test-proxy-release-artifacts.sh | 97 ++++++++++++++ scripts/ci/test-release-gates.sh | 104 +++++++++++++++ scripts/ci/verify-release-artifacts.sh | 126 +++++++++++++++++- 12 files changed, 633 insertions(+), 8 deletions(-) create mode 100755 scripts/ci/proxy-release.sh create mode 100755 scripts/ci/test-proxy-release-artifacts.sh diff --git a/.agents/skills/release-maple/SKILL.md b/.agents/skills/release-maple/SKILL.md index bd0bf9c9b..7d416d5e2 100644 --- a/.agents/skills/release-maple/SKILL.md +++ b/.agents/skills/release-maple/SKILL.md @@ -18,6 +18,9 @@ commit, external effect, and authority provided by the user. successful release workflow starts separate updater-metadata, Pages production-branch, and best-effort Zapstore workflows. These sibling workflows never gate or change the outcome of the core Maple release. +- The same Maple GitHub Release receives four native `maple-proxy` archives and + their checksum manifest. Never create a separate proxy Release or proxy tag; + `/releases/latest` must continue to identify the Maple application release. - GitHub Release creation does not itself submit the release IPA or AAB to Apple App Store review or Google Play. @@ -122,7 +125,8 @@ gh run watch RELEASE_RUN_ID \ ``` Stay with every platform build, signature/canonical proof, artifact upload, -updater `latest.json`, aggregate verification, and verification-guide step. +the four native proxy builds and their published-asset verification, updater +`latest.json`, aggregate verification, and verification-guide step. Packaging success alone is not runtime smoke; inspect the workflow's actual verification and attestation results. @@ -171,6 +175,11 @@ Verify the published release and its assets: ```bash gh release view "$tag" --repo OpenSecretCloud/Maple \ --json tagName,name,isDraft,isPrerelease,publishedAt,targetCommitish,url,assets + +mkdir -p artifacts +gh release download "$tag" --repo OpenSecretCloud/Maple --dir artifacts +nix develop --no-update-lock-file .#ci -c \ + ./scripts/ci/verify-release-artifacts.sh artifacts proxy ``` Zapstore starts only after `Release` succeeds and is strictly best effort. Its diff --git a/.github/workflows/release-gates-tests.yml b/.github/workflows/release-gates-tests.yml index 16b63489b..abfc72679 100644 --- a/.github/workflows/release-gates-tests.yml +++ b/.github/workflows/release-gates-tests.yml @@ -8,8 +8,14 @@ on: - ".github/workflows/pages-production.yml" - ".github/workflows/zapstore-publish.yml" - "scripts/ci/classify-app-release.sh" + - "scripts/ci/proxy-release.sh" + - "scripts/ci/test-proxy-release-artifacts.sh" - "scripts/ci/test-release-gates.sh" - "scripts/ci/validate-release-version.sh" + - "scripts/ci/verify-release-artifacts.sh" + - "proxy/Cargo.toml" + - "proxy/Cargo.lock" + - "proxy/src/**" - "frontend/package.json" - "frontend/src-tauri/Cargo.toml" - "frontend/src-tauri/tauri.conf.json" @@ -23,8 +29,14 @@ on: - ".github/workflows/pages-production.yml" - ".github/workflows/zapstore-publish.yml" - "scripts/ci/classify-app-release.sh" + - "scripts/ci/proxy-release.sh" + - "scripts/ci/test-proxy-release-artifacts.sh" - "scripts/ci/test-release-gates.sh" - "scripts/ci/validate-release-version.sh" + - "scripts/ci/verify-release-artifacts.sh" + - "proxy/Cargo.toml" + - "proxy/Cargo.lock" + - "proxy/src/**" - "frontend/package.json" - "frontend/src-tauri/Cargo.toml" - "frontend/src-tauri/tauri.conf.json" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 640e083c9..29269b3a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -649,6 +649,146 @@ jobs: frontend/src-tauri/target/reproducibility/web-final.sha256 \ --clobber + build-proxy: + name: Build proxy (${{ matrix.archive }}) + needs: classify-app-release + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + archive: maple-proxy-linux-x86_64.tar.gz + - runner: ubuntu-24.04-arm + archive: maple-proxy-linux-aarch64.tar.gz + - runner: macos-26-xlarge + archive: maple-proxy-macos-aarch64.tar.gz + - runner: windows-2025 + archive: maple-proxy-windows-x86_64.zip + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 + with: + ref: ${{ needs.classify-app-release.outputs.release_sha }} + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # was stable + with: + toolchain: 1.89.0 + + - name: Cache proxy Rust dependencies + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # was v2 + with: + workspaces: proxy -> target + add-rust-environment-hash-key: "false" + key: release-${{ matrix.archive }}-${{ hashFiles('proxy/Cargo.lock') }} + + - name: Build and package native proxy binary + shell: bash + run: ./scripts/ci/proxy-release.sh proxy-release-assets "${{ matrix.archive }}" + + - name: Upload native proxy binary + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 + with: + name: proxy-release-${{ matrix.archive }} + path: proxy-release-assets/${{ matrix.archive }} + if-no-files-found: error + retention-days: 7 + + publish-proxy-release-artifacts: + needs: + - classify-app-release + - build-proxy + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 + with: + ref: ${{ needs.classify-app-release.outputs.release_sha }} + persist-credentials: false + + - name: Download native proxy binaries + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # was v4 + with: + pattern: proxy-release-* + path: proxy-release-assets + merge-multiple: true + + - name: Finalize and verify proxy release assets + run: | + set -euo pipefail + assets=( + maple-proxy-linux-aarch64.tar.gz + maple-proxy-linux-x86_64.tar.gz + maple-proxy-macos-aarch64.tar.gz + maple-proxy-windows-x86_64.zip + ) + ( + cd proxy-release-assets + sha256sum "${assets[@]}" > maple-proxy-release-final.sha256 + ) + ./scripts/ci/verify-release-artifacts.sh proxy-release-assets proxy + + attested_assets=( + proxy-release-assets/maple-proxy-linux-aarch64.tar.gz + proxy-release-assets/maple-proxy-linux-x86_64.tar.gz + proxy-release-assets/maple-proxy-macos-aarch64.tar.gz + proxy-release-assets/maple-proxy-windows-x86_64.zip + proxy-release-assets/maple-proxy-release-final.sha256 + ) + sha256sum "${attested_assets[@]}" > proxy-release-attestation.sha256 + cat proxy-release-assets/maple-proxy-release-final.sha256 + + - name: Attest proxy release assets + uses: actions/attest@281a49d4cbb0a72c9575a50d18f6deb515a11deb # was v4 + with: + subject-checksums: proxy-release-attestation.sha256 + + - name: Upload proxy assets to the Maple release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.classify-app-release.outputs.tag }} + run: | + set -euo pipefail + gh release upload "${RELEASE_TAG}" \ + proxy-release-assets/maple-proxy-linux-aarch64.tar.gz \ + proxy-release-assets/maple-proxy-linux-x86_64.tar.gz \ + proxy-release-assets/maple-proxy-macos-aarch64.tar.gz \ + proxy-release-assets/maple-proxy-windows-x86_64.zip \ + proxy-release-assets/maple-proxy-release-final.sha256 \ + --clobber + + verify-proxy-release-artifacts: + needs: + - classify-app-release + - publish-proxy-release-artifacts + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 + with: + ref: ${{ needs.classify-app-release.outputs.release_sha }} + persist-credentials: false + + - name: Download release artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.classify-app-release.outputs.tag }} + run: | + mkdir -p artifacts + gh release download "${RELEASE_TAG}" -D artifacts + + - name: Verify published proxy release assets + run: ./scripts/ci/verify-release-artifacts.sh artifacts proxy + verify-android-release-artifacts: needs: - classify-app-release @@ -864,6 +1004,7 @@ jobs: - build-web - update-latest-json - verify-android-release-artifacts + - verify-proxy-release-artifacts runs-on: macos-26-xlarge permissions: contents: read @@ -906,7 +1047,7 @@ jobs: gh release download "${RELEASE_TAG}" -D artifacts - name: Verify release artifact reproducibility proofs - run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts macos windows ios web latest-json + run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts macos windows ios web latest-json proxy env: MAPLE_ENFORCE_IOS_SIGNED_REPRODUCIBILITY: "1" diff --git a/README.md b/README.md index cca39fa7a..7849e8cba 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,8 @@ generated output opportunistically. Release preparation and publication are production actions. A push to `master` that changes classified Maple app inputs starts production-shaped signed workflows and can upload an iOS build to TestFlight; creating a GitHub -Release always starts the complete release pipeline and downstream publication. +Release always starts the complete release pipeline, attaches the four native +`maple-proxy` archives to that same release, and starts downstream publication. Do not use either as routine validation. Use `.agents/skills/release-maple/` for version parity, tag safety, workflow diff --git a/flake.nix b/flake.nix index c262325e5..dba4b3124 100644 --- a/flake.nix +++ b/flake.nix @@ -702,6 +702,10 @@ git jq python3 + ripgrep + gnutar + unzip + zip yq-go ]; src = ./.; diff --git a/proxy/README.md b/proxy/README.md index a96f3b61a..c36f4d437 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -19,6 +19,19 @@ Environment (TEE) processing. ### As a Binary +Every Maple GitHub Release includes native proxy archives for Linux x86_64, +Linux ARM64, Apple Silicon macOS, and Windows x86_64. The stable download URLs +use the ordinary Maple release, for example: + +```bash +curl -LO https://github.com/OpenSecretCloud/Maple/releases/latest/download/maple-proxy-linux-x86_64.tar.gz +curl -LO https://github.com/OpenSecretCloud/Maple/releases/latest/download/maple-proxy-release-final.sha256 +sha256sum --check --ignore-missing maple-proxy-release-final.sha256 +``` + +There is no separate proxy GitHub Release or proxy release tag. To build from +source instead: + ```bash git clone https://github.com/OpenSecretCloud/Maple.git cd Maple/proxy diff --git a/proxy/src/config.rs b/proxy/src/config.rs index b0e11263c..ee8080a9c 100644 --- a/proxy/src/config.rs +++ b/proxy/src/config.rs @@ -9,6 +9,7 @@ pub const DEFAULT_STREAM_IDLE_TIMEOUT_SECS: u64 = 300; #[derive(Parser, Debug, Clone)] #[command(name = "maple-proxy")] #[command(about = "Lightweight OpenAI-compatible proxy server for Maple/OpenSecret")] +#[command(version)] pub struct Config { /// Host to bind the server to #[arg(long, env = "MAPLE_HOST", default_value = "127.0.0.1")] diff --git a/scripts/ci/proxy-release.sh b/scripts/ci/proxy-release.sh new file mode 100755 index 000000000..5140c6526 --- /dev/null +++ b/scripts/ci/proxy-release.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +usage: proxy-release.sh + +Builds maple-proxy for the current native runner, checks its --version output, +and packages the binary under the stable release asset name for that runner. +EOF +} + +output_dir="${1:-}" +archive_name="${2:-}" +if [ -z "${output_dir}" ] || [ -z "${archive_name}" ] || [ "$#" -ne 2 ]; then + usage + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd -P)" +manifest="${repo_root}/proxy/Cargo.toml" +target_dir="${repo_root}/proxy/target" +host_os="$(uname -s)" +host_arch="$(uname -m)" + +case "${host_os}:${host_arch}" in + Linux:x86_64) + expected_archive="maple-proxy-linux-x86_64.tar.gz" + binary_name="maple-proxy" + ;; + Linux:aarch64 | Linux:arm64) + expected_archive="maple-proxy-linux-aarch64.tar.gz" + binary_name="maple-proxy" + ;; + Darwin:arm64 | Darwin:aarch64) + expected_archive="maple-proxy-macos-aarch64.tar.gz" + binary_name="maple-proxy" + ;; + MINGW*:x86_64 | MSYS*:x86_64 | CYGWIN*:x86_64) + expected_archive="maple-proxy-windows-x86_64.zip" + binary_name="maple-proxy.exe" + ;; + *) + echo "Unsupported maple-proxy release host: ${host_os} ${host_arch}" >&2 + exit 1 + ;; +esac + +if [ "${archive_name}" != "${expected_archive}" ]; then + echo "Archive ${archive_name} does not match native host ${host_os} ${host_arch}; expected ${expected_archive}." >&2 + exit 1 +fi + +proxy_version="$(awk ' + /^\[package\]$/ { in_package = 1; next } + /^\[/ && in_package { exit } + in_package && /^version[[:space:]]*=/ { + value = $0 + sub(/^[^=]*=[[:space:]]*"/, "", value) + sub(/"[[:space:]]*$/, "", value) + print value + exit + } +' "${manifest}")" +if [ -z "${proxy_version}" ]; then + echo "Could not read maple-proxy package version." >&2 + exit 1 +fi + +cargo build --locked --manifest-path "${manifest}" --release --bin maple-proxy + +binary="${target_dir}/release/${binary_name}" +if [ ! -f "${binary}" ]; then + echo "Built maple-proxy binary is missing: ${binary}" >&2 + exit 1 +fi + +actual_version="$("${binary}" --version)" +expected_version="maple-proxy ${proxy_version}" +if [ "${actual_version}" != "${expected_version}" ]; then + echo "Unexpected maple-proxy version output." >&2 + echo "expected=${expected_version}" >&2 + echo "actual=${actual_version}" >&2 + exit 1 +fi + +mkdir -p "${output_dir}" +output_dir="$(cd "${output_dir}" && pwd -P)" +archive="${output_dir}/${archive_name}" +stage_dir="$(mktemp -d)" +trap 'rm -rf "${stage_dir}"' EXIT HUP INT TERM +cp "${binary}" "${stage_dir}/${binary_name}" + +case "${archive_name}" in + *.tar.gz) + tar -C "${stage_dir}" -czf "${archive}" "${binary_name}" + ;; + *.zip) + command -v 7z >/dev/null 2>&1 || { + echo "7z is required to package the Windows proxy binary." >&2 + exit 1 + } + ( + cd "${stage_dir}" + 7z a -tzip "${archive}" "${binary_name}" >/dev/null + ) + ;; + *) + echo "Unsupported proxy archive format: ${archive_name}" >&2 + exit 1 + ;; +esac + +printf 'built-proxy-release-asset %s %s\n' "${actual_version}" "${archive}" diff --git a/scripts/ci/release-verification-guide.sh b/scripts/ci/release-verification-guide.sh index 1ac7a4202..8d87e84b6 100755 --- a/scripts/ci/release-verification-guide.sh +++ b/scripts/ci/release-verification-guide.sh @@ -58,9 +58,9 @@ This guide was generated by release CI after Maple's release artifact verifier p ## What The Proofs Cover -The release contains final SHA-256 manifests, canonical payload manifests, Tauri updater signatures, and GitHub artifact attestations. Together they let you check that downloaded bytes match the release, that updater signatures match \`latest.json\`, and that signed Android, macOS, and iOS payloads strip back to the reproducible unsigned build products where that platform supports the check. Windows release proofs cover the final Authenticode-signed NSIS installer, its final Tauri updater signature, the pinned app-local runtime DLLs used during bundling, and the signed installer's canonical app/runtime payload after excluding the NSIS-generated uninstaller helper. +The release contains final SHA-256 manifests, canonical payload manifests, Tauri updater signatures, and GitHub artifact attestations. Together they let you check that downloaded bytes match the release, that updater signatures match \`latest.json\`, and that signed Android, macOS, and iOS payloads strip back to the reproducible unsigned build products where that platform supports the check. Windows release proofs cover the final Authenticode-signed NSIS installer, its final Tauri updater signature, the pinned app-local runtime DLLs used during bundling, and the signed installer's canonical app/runtime payload after excluding the NSIS-generated uninstaller helper. The proxy proof covers the four native proxy archives and verifies that each contains only the expected executable. -GitHub attestations prove that release CI produced the published bytes for this tag. The Maple verifier then recomputes the local hashes and canonical payload proofs from the files you downloaded. +GitHub attestations prove that release CI produced the assets explicitly attested by their build jobs for this tag. The Maple verifier then recomputes the local hashes and canonical payload proofs from the files you downloaded. ## Download The Release And Verifier @@ -78,10 +78,11 @@ The \`gh release download\` command is the easiest path because it downloads the ## Verify GitHub Attestations -To verify that each downloaded release asset has a GitHub provenance attestation for this tag: +For example, the proxy publisher attests every proxy archive and its checksum +manifest, so you can verify them with: \`\`\`bash -for file in artifacts/*; do +for file in artifacts/maple-proxy-*; do gh attestation verify "\${file}" --repo ${repo} --source-ref ${source_ref} done \`\`\` @@ -121,6 +122,9 @@ MAPLE_REQUIRE_ANDROID_SIGNATURE_VERIFICATION=1 \\ # Web archive and Tauri updater metadata. nix develop --no-update-lock-file .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts web latest-json + +# Native maple-proxy archives and their unified checksum manifest. +nix develop --no-update-lock-file .#ci -c ./scripts/ci/verify-release-artifacts.sh artifacts proxy \`\`\` Successful checks print \`verified-...\` lines and exit with status 0. A mismatch exits non-zero and prints the expected and actual digest. diff --git a/scripts/ci/test-proxy-release-artifacts.sh b/scripts/ci/test-proxy-release-artifacts.sh new file mode 100755 index 000000000..7fb283c08 --- /dev/null +++ b/scripts/ci/test-proxy-release-artifacts.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +verifier="${script_dir}/verify-release-artifacts.sh" +temp_root="$(mktemp -d)" +trap 'rm -rf "${temp_root}"' EXIT HUP INT TERM + +passed=0 + +pass() { + passed=$((passed + 1)) + printf 'ok %d - %s\n' "${passed}" "$1" +} + +fail() { + echo "not ok - $*" >&2 + exit 1 +} + +expect_failure() { + local label="$1" + shift + + if output="$("$@" 2>&1)"; then + printf '%s\n' "${output}" >&2 + fail "${label}" + fi + pass "${label}" +} + +make_fixture() { + local directory="$1" + local stage="${directory}/stage" + + mkdir -p "${directory}" "${stage}" + printf '#!/usr/bin/env sh\nprintf "maple-proxy fixture\\n"\n' > "${stage}/maple-proxy" + printf 'fixture windows binary\n' > "${stage}/maple-proxy.exe" + + tar -C "${stage}" -czf "${directory}/maple-proxy-linux-aarch64.tar.gz" maple-proxy + tar -C "${stage}" -czf "${directory}/maple-proxy-linux-x86_64.tar.gz" maple-proxy + tar -C "${stage}" -czf "${directory}/maple-proxy-macos-aarch64.tar.gz" maple-proxy + ( + cd "${stage}" + zip -q "${directory}/maple-proxy-windows-x86_64.zip" maple-proxy.exe + ) + ( + cd "${directory}" + sha256sum \ + maple-proxy-linux-aarch64.tar.gz \ + maple-proxy-linux-x86_64.tar.gz \ + maple-proxy-macos-aarch64.tar.gz \ + maple-proxy-windows-x86_64.zip \ + > maple-proxy-release-final.sha256 + ) + rm -rf "${stage}" +} + +valid="${temp_root}/valid" +make_fixture "${valid}" +"${verifier}" "${valid}" proxy >/dev/null +pass "accepts the complete proxy release asset set" + +missing="${temp_root}/missing" +cp -R "${valid}" "${missing}" +rm "${missing}/maple-proxy-linux-aarch64.tar.gz" +expect_failure "rejects a missing proxy release asset" "${verifier}" "${missing}" proxy + +tampered="${temp_root}/tampered" +cp -R "${valid}" "${tampered}" +printf 'tampered\n' >> "${tampered}/maple-proxy-macos-aarch64.tar.gz" +expect_failure "rejects a proxy release hash mismatch" "${verifier}" "${tampered}" proxy + +extra_member="${temp_root}/extra-member" +cp -R "${valid}" "${extra_member}" +mkdir -p "${extra_member}/stage" +printf 'proxy\n' > "${extra_member}/stage/maple-proxy" +printf 'unexpected\n' > "${extra_member}/stage/README.txt" +tar -C "${extra_member}/stage" -czf "${extra_member}/maple-proxy-linux-x86_64.tar.gz" maple-proxy README.txt +( + cd "${extra_member}" + sha256sum \ + maple-proxy-linux-aarch64.tar.gz \ + maple-proxy-linux-x86_64.tar.gz \ + maple-proxy-macos-aarch64.tar.gz \ + maple-proxy-windows-x86_64.zip \ + > maple-proxy-release-final.sha256 +) +rm -rf "${extra_member}/stage" +expect_failure "rejects an archive with extra members" "${verifier}" "${extra_member}" proxy + +unexpected="${temp_root}/unexpected" +cp -R "${valid}" "${unexpected}" +cp "${unexpected}/maple-proxy-linux-x86_64.tar.gz" "${unexpected}/maple-proxy-linux-riscv64.tar.gz" +expect_failure "rejects an unexpected proxy release archive" "${verifier}" "${unexpected}" proxy + +printf '1..%d\n' "${passed}" diff --git a/scripts/ci/test-release-gates.sh b/scripts/ci/test-release-gates.sh index e6a4531cc..405c42c8a 100755 --- a/scripts/ci/test-release-gates.sh +++ b/scripts/ci/test-release-gates.sh @@ -164,6 +164,13 @@ pages_production_json="${temp_root}/pages-production.json" yq -o=json '.' "${repo_root}/.github/workflows/release.yml" > "${release_json}" yq -o=json '.' "${repo_root}/.github/workflows/pages-production.yml" > "${pages_production_json}" +if rg -n --glob '*.yml' --glob '*.yaml' \ + 'gh[[:space:]]+release[[:space:]]+create|softprops/action-gh-release' \ + "${repo_root}/.github/workflows"; then + fail "repository workflows must not create a second GitHub Release" +fi +pass "repository workflows preserve one Maple GitHub Release object" + python3 - "${release_json}" "${pages_production_json}" <<'PY' import json import re @@ -252,6 +259,100 @@ for job_id, job in release_jobs.items(): check(checkout.get("ref") == release_ref, f"Release checkout in {job_id} must pin classifier SHA") check(checkout.get("persist-credentials") is False, f"Release checkout in {job_id} must not persist credentials") +proxy_assets = { + "maple-proxy-linux-aarch64.tar.gz", + "maple-proxy-linux-x86_64.tar.gz", + "maple-proxy-macos-aarch64.tar.gz", + "maple-proxy-windows-x86_64.zip", +} +for job_id in ( + "build-proxy", + "publish-proxy-release-artifacts", + "verify-proxy-release-artifacts", +): + check(job_id in release_jobs, f"Release workflow must have {job_id} job") + +proxy_build = release_jobs["build-proxy"] +proxy_matrix = proxy_build.get("strategy", {}).get("matrix", {}).get("include", []) +check( + {entry.get("archive") for entry in proxy_matrix} == proxy_assets, + "Proxy release matrix must build the four stable native asset names", +) +check( + len(proxy_matrix) == len(proxy_assets), + "Proxy release matrix must contain each native asset exactly once", +) +check( + proxy_build.get("permissions") == {"contents": "read"}, + "Proxy native builds must have only contents: read permission", +) + +proxy_publish = release_jobs["publish-proxy-release-artifacts"] +check( + set(needs(proxy_publish)) == {classifier_id, "build-proxy"}, + "Proxy publisher must wait for classification and every native proxy build", +) +check( + proxy_publish.get("permissions") + == { + "contents": "write", + "id-token": "write", + "attestations": "write", + "artifact-metadata": "write", + }, + "Proxy publisher must have only release-upload and attestation permissions", +) +proxy_attest_steps = [ + step + for step in proxy_publish.get("steps", []) + if step.get("name") == "Attest proxy release assets" +] +check(len(proxy_attest_steps) == 1, "Proxy release assets must be attested exactly once") +check( + proxy_attest_steps[0].get("continue-on-error") is not True, + "Proxy release attestation must fail closed", +) +proxy_upload_steps = [ + step + for step in proxy_publish.get("steps", []) + if step.get("name") == "Upload proxy assets to the Maple release" +] +check(len(proxy_upload_steps) == 1, "Proxy assets must upload exactly once") +proxy_upload_run = str(proxy_upload_steps[0].get("run", "")) +check( + 'gh release upload "${RELEASE_TAG}"' in proxy_upload_run, + "Proxy publisher must upload to the classified Maple release tag", +) +for asset in proxy_assets | {"maple-proxy-release-final.sha256"}: + check(asset in proxy_upload_run, f"Proxy publisher must upload {asset}") + +proxy_verify = release_jobs["verify-proxy-release-artifacts"] +check( + set(needs(proxy_verify)) + == {classifier_id, "publish-proxy-release-artifacts"}, + "Published proxy verification must wait for the proxy publisher", +) + +latest_needs = set(needs(release_jobs["update-latest-json"])) +check( + not latest_needs.intersection( + {"build-proxy", "publish-proxy-release-artifacts", "verify-proxy-release-artifacts"} + ), + "latest.json publication must remain independent of proxy release jobs", +) + +aggregate = release_jobs["verify-release-artifacts"] +check( + "verify-proxy-release-artifacts" in needs(aggregate), + "Aggregate release verification must wait for published proxy verification", +) +aggregate_runs = "\n".join(str(step.get("run", "")) for step in aggregate.get("steps", [])) +check( + "verify-release-artifacts.sh artifacts macos windows ios web latest-json proxy" + in aggregate_runs, + "Aggregate release verification must include proxy assets", +) + check( pages_production.get("permissions") == {"contents": "read"}, "Pages production workflow must default to contents: read", @@ -313,4 +414,7 @@ for required_control in ( PY pass "workflow release-gate topology is fail closed with isolated downstream publishers" +bash "${script_dir}/test-proxy-release-artifacts.sh" >/dev/null +pass "proxy release artifact verifier accepts only the complete native asset set" + printf '1..%d\n' "${passed}" diff --git a/scripts/ci/verify-release-artifacts.sh b/scripts/ci/verify-release-artifacts.sh index 3a2ac13d9..c357cc740 100755 --- a/scripts/ci/verify-release-artifacts.sh +++ b/scripts/ci/verify-release-artifacts.sh @@ -5,7 +5,7 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" usage() { cat >&2 <<'EOF' -usage: verify-release-artifacts.sh [all|present|linux|macos|windows|android|ios|web|latest-json ...] +usage: verify-release-artifacts.sh [all|present|linux|macos|windows|android|ios|web|latest-json|proxy ...] Verifies downloaded release artifacts against their reproducibility proof files. The verifier recomputes final file hashes, canonical signed payload hashes, and @@ -799,6 +799,125 @@ verify_latest_json() { done } +proxy_asset_required() { + local name="$1" + local count file + + count="$(find "${artifacts_dir}" -type f -name "${name}" | wc -l | tr -d '[:space:]')" + if [ "${count}" != "1" ]; then + echo "Expected exactly one proxy release asset named ${name}; found ${count}." >&2 + return 1 + fi + + file="$(find "${artifacts_dir}" -type f -name "${name}" | LC_ALL=C sort | head -n 1)" + printf '%s\n' "${file}" +} + +verify_proxy_archive_member() { + local archive_name="$1" + local archive="$2" + local members expected_member + + case "${archive_name}" in + *.tar.gz) + members="$(tar -tzf "${archive}")" + expected_member="maple-proxy" + ;; + *.zip) + members="$(unzip -Z1 "${archive}")" + expected_member="maple-proxy.exe" + ;; + *) + echo "Unsupported proxy archive format: ${archive_name}" >&2 + return 1 + ;; + esac + + if [ "${members}" != "${expected_member}" ]; then + echo "Proxy archive ${archive_name} must contain only ${expected_member}." >&2 + printf 'members=%s\n' "${members}" >&2 + return 1 + fi + + printf 'verified-proxy-archive-member %s %s\n' "${archive_name}" "${expected_member}" +} + +verify_proxy() { + local manifest manifest_count digest label extra archive actual archive_name + local count=0 + local expected_assets=( + maple-proxy-linux-aarch64.tar.gz + maple-proxy-linux-x86_64.tar.gz + maple-proxy-macos-aarch64.tar.gz + maple-proxy-windows-x86_64.zip + ) + declare -A expected=() + declare -A seen=() + + manifest_count="$(find "${artifacts_dir}" -type f -name maple-proxy-release-final.sha256 | wc -l | tr -d '[:space:]')" + if [ "${manifest_count}" != "1" ]; then + echo "Expected exactly one proxy release manifest; found ${manifest_count}." >&2 + return 1 + fi + manifest="$(proof_file_required maple-proxy-release-final.sha256)" + + for archive_name in "${expected_assets[@]}"; do + expected["${archive_name}"]=1 + seen["${archive_name}"]=0 + proxy_asset_required "${archive_name}" >/dev/null + done + + while read -r digest label extra; do + [ -n "${digest:-}" ] || continue + + if ! [[ "${digest}" =~ ^[0-9a-fA-F]{64}$ ]] || [ -z "${label:-}" ] || [ -n "${extra:-}" ]; then + echo "Invalid proxy release manifest line in ${manifest}: ${digest:-} ${label:-} ${extra:-}" >&2 + return 1 + fi + if [ "$(basename "${label}")" != "${label}" ] || [ -z "${expected[${label}]+x}" ]; then + echo "Unexpected proxy release manifest asset: ${label}" >&2 + return 1 + fi + if [ "${seen[${label}]}" = "1" ]; then + echo "Duplicate proxy release manifest asset: ${label}" >&2 + return 1 + fi + + archive="$(proxy_asset_required "${label}")" + actual="$(sha256_file "${archive}" | awk '{ print $1 }')" + if [ "${actual}" != "${digest}" ]; then + echo "Proxy release asset hash mismatch for ${label}." >&2 + echo "expected=${digest}" >&2 + echo "actual=${actual}" >&2 + return 1 + fi + + verify_proxy_archive_member "${label}" "${archive}" + printf 'verified-proxy-release-asset %s %s\n' "${actual}" "${label}" + seen["${label}"]=1 + count=$((count + 1)) + done < "${manifest}" + + for archive_name in "${expected_assets[@]}"; do + if [ "${seen[${archive_name}]}" != "1" ]; then + echo "Missing proxy release manifest asset: ${archive_name}" >&2 + return 1 + fi + done + if [ "${count}" -ne "${#expected_assets[@]}" ]; then + echo "Unexpected proxy release manifest entry count: ${count}" >&2 + return 1 + fi + + while IFS= read -r -d '' archive; do + archive_name="$(basename "${archive}")" + if [ -z "${expected[${archive_name}]+x}" ]; then + echo "Unexpected proxy release archive: ${archive_name}" >&2 + return 1 + fi + done < <(find "${artifacts_dir}" -type f \( -name 'maple-proxy-*.tar.gz' -o -name 'maple-proxy-*.zip' \) -print0 | LC_ALL=C sort -z) +} + target_present() { local pattern="$1" [ -n "$(proof_file_optional "${pattern}")" ] @@ -816,6 +935,7 @@ verify_present() { target_present ios-release-final.sha256 && verify_ios target_present web-final.sha256 && verify_web target_present latest-json-final.sha256 && verify_latest_json + target_present maple-proxy-release-final.sha256 && verify_proxy return 0 } @@ -827,6 +947,7 @@ verify_all() { verify_ios verify_web verify_latest_json + verify_proxy } for target in "$@"; do @@ -858,6 +979,9 @@ for target in "$@"; do latest-json) verify_latest_json ;; + proxy) + verify_proxy + ;; *) usage exit 2 From 8f95e7d1cc37afb6647c113287b88d5a6ef8d0d9 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:59:05 +0000 Subject: [PATCH 2/3] test: run proxy verifier through Nix bash --- scripts/ci/test-proxy-release-artifacts.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/ci/test-proxy-release-artifacts.sh b/scripts/ci/test-proxy-release-artifacts.sh index 7fb283c08..3ef3e54c7 100755 --- a/scripts/ci/test-proxy-release-artifacts.sh +++ b/scripts/ci/test-proxy-release-artifacts.sh @@ -58,18 +58,18 @@ make_fixture() { valid="${temp_root}/valid" make_fixture "${valid}" -"${verifier}" "${valid}" proxy >/dev/null +bash "${verifier}" "${valid}" proxy >/dev/null pass "accepts the complete proxy release asset set" missing="${temp_root}/missing" cp -R "${valid}" "${missing}" rm "${missing}/maple-proxy-linux-aarch64.tar.gz" -expect_failure "rejects a missing proxy release asset" "${verifier}" "${missing}" proxy +expect_failure "rejects a missing proxy release asset" bash "${verifier}" "${missing}" proxy tampered="${temp_root}/tampered" cp -R "${valid}" "${tampered}" printf 'tampered\n' >> "${tampered}/maple-proxy-macos-aarch64.tar.gz" -expect_failure "rejects a proxy release hash mismatch" "${verifier}" "${tampered}" proxy +expect_failure "rejects a proxy release hash mismatch" bash "${verifier}" "${tampered}" proxy extra_member="${temp_root}/extra-member" cp -R "${valid}" "${extra_member}" @@ -87,11 +87,11 @@ tar -C "${extra_member}/stage" -czf "${extra_member}/maple-proxy-linux-x86_64.ta > maple-proxy-release-final.sha256 ) rm -rf "${extra_member}/stage" -expect_failure "rejects an archive with extra members" "${verifier}" "${extra_member}" proxy +expect_failure "rejects an archive with extra members" bash "${verifier}" "${extra_member}" proxy unexpected="${temp_root}/unexpected" cp -R "${valid}" "${unexpected}" cp "${unexpected}/maple-proxy-linux-x86_64.tar.gz" "${unexpected}/maple-proxy-linux-riscv64.tar.gz" -expect_failure "rejects an unexpected proxy release archive" "${verifier}" "${unexpected}" proxy +expect_failure "rejects an unexpected proxy release archive" bash "${verifier}" "${unexpected}" proxy printf '1..%d\n' "${passed}" From 7e4da934ea19f5a76708d33ef42f770f599712ea Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:01:34 +0000 Subject: [PATCH 3/3] ci: rehearse proxy release artifacts natively --- .github/workflows/proxy-rust.yml | 39 +++++++++++++++++++++++ .github/workflows/release-gates-tests.yml | 2 ++ scripts/ci/test-release-gates.sh | 24 +++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/.github/workflows/proxy-rust.yml b/.github/workflows/proxy-rust.yml index 58fe9f35d..dd8af5397 100644 --- a/.github/workflows/proxy-rust.yml +++ b/.github/workflows/proxy-rust.yml @@ -18,6 +18,7 @@ on: - "proxy/src/**" - "proxy/tests/**" - "proxy/examples/**" + - "scripts/ci/proxy-release.sh" - "sdk/rust/Cargo.toml" - "sdk/rust/src/**" - "sdk/rust/assets/**" @@ -34,6 +35,7 @@ on: - "proxy/src/**" - "proxy/tests/**" - "proxy/examples/**" + - "scripts/ci/proxy-release.sh" - "sdk/rust/Cargo.toml" - "sdk/rust/src/**" - "sdk/rust/assets/**" @@ -73,3 +75,40 @@ jobs: RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps --all-features cargo machete ' + + proxy-native-release: + name: proxy-native-release (${{ matrix.archive }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + archive: maple-proxy-linux-x86_64.tar.gz + - runner: ubuntu-24.04-arm + archive: maple-proxy-linux-aarch64.tar.gz + - runner: macos-26-xlarge + archive: maple-proxy-macos-aarch64.tar.gz + - runner: windows-2025 + archive: maple-proxy-windows-x86_64.zip + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # was stable + with: + toolchain: 1.89.0 + + - name: Cache proxy Rust dependencies + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # was v2 + with: + workspaces: proxy -> target + add-rust-environment-hash-key: "false" + key: native-release-${{ matrix.archive }}-${{ hashFiles('proxy/Cargo.lock') }} + + - name: Rehearse native proxy release asset + shell: bash + run: ./scripts/ci/proxy-release.sh proxy-release-rehearsal "${{ matrix.archive }}" diff --git a/.github/workflows/release-gates-tests.yml b/.github/workflows/release-gates-tests.yml index abfc72679..79ed41224 100644 --- a/.github/workflows/release-gates-tests.yml +++ b/.github/workflows/release-gates-tests.yml @@ -6,6 +6,7 @@ on: - ".github/workflows/release.yml" - ".github/workflows/release-gates-tests.yml" - ".github/workflows/pages-production.yml" + - ".github/workflows/proxy-rust.yml" - ".github/workflows/zapstore-publish.yml" - "scripts/ci/classify-app-release.sh" - "scripts/ci/proxy-release.sh" @@ -27,6 +28,7 @@ on: - ".github/workflows/release.yml" - ".github/workflows/release-gates-tests.yml" - ".github/workflows/pages-production.yml" + - ".github/workflows/proxy-rust.yml" - ".github/workflows/zapstore-publish.yml" - "scripts/ci/classify-app-release.sh" - "scripts/ci/proxy-release.sh" diff --git a/scripts/ci/test-release-gates.sh b/scripts/ci/test-release-gates.sh index 405c42c8a..304e6bd23 100755 --- a/scripts/ci/test-release-gates.sh +++ b/scripts/ci/test-release-gates.sh @@ -161,8 +161,10 @@ expect_failure "rejects an unknown argument" \ release_json="${temp_root}/release.json" pages_production_json="${temp_root}/pages-production.json" +proxy_rust_json="${temp_root}/proxy-rust.json" yq -o=json '.' "${repo_root}/.github/workflows/release.yml" > "${release_json}" yq -o=json '.' "${repo_root}/.github/workflows/pages-production.yml" > "${pages_production_json}" +yq -o=json '.' "${repo_root}/.github/workflows/proxy-rust.yml" > "${proxy_rust_json}" if rg -n --glob '*.yml' --glob '*.yaml' \ 'gh[[:space:]]+release[[:space:]]+create|softprops/action-gh-release' \ @@ -171,7 +173,7 @@ if rg -n --glob '*.yml' --glob '*.yaml' \ fi pass "repository workflows preserve one Maple GitHub Release object" -python3 - "${release_json}" "${pages_production_json}" <<'PY' +python3 - "${release_json}" "${pages_production_json}" "${proxy_rust_json}" <<'PY' import json import re import sys @@ -213,6 +215,9 @@ with open(sys.argv[1], encoding="utf-8") as handle: with open(sys.argv[2], encoding="utf-8") as handle: pages_production = json.load(handle) +with open(sys.argv[3], encoding="utf-8") as handle: + proxy_rust = json.load(handle) + release_jobs = release["jobs"] classifier_id = "classify-app-release" check(classifier_id in release_jobs, "Release workflow must have classify-app-release job") @@ -287,6 +292,23 @@ check( "Proxy native builds must have only contents: read permission", ) +proxy_rehearsal = proxy_rust.get("jobs", {}).get("proxy-native-release", {}) +proxy_rehearsal_matrix = ( + proxy_rehearsal.get("strategy", {}).get("matrix", {}).get("include", []) +) +check( + proxy_rehearsal_matrix == proxy_matrix, + "Proxy PR rehearsal and release matrices must remain identical", +) +proxy_rehearsal_runs = "\n".join( + str(step.get("run", "")) for step in proxy_rehearsal.get("steps", []) +) +check( + 'proxy-release.sh proxy-release-rehearsal "${{ matrix.archive }}"' + in proxy_rehearsal_runs, + "Proxy PR rehearsal must run the release packaging script", +) + proxy_publish = release_jobs["publish-proxy-release-artifacts"] check( set(needs(proxy_publish)) == {classifier_id, "build-proxy"},