From 2aa6920fb1568249d2466fa1b13760d1a51c0e8f Mon Sep 17 00:00:00 2001 From: Lena Date: Wed, 1 Jul 2026 00:00:00 +0000 Subject: ci: pin toolchains and verify release artifacts Checksum-pin every downloaded toolchain archive. Before publishing, verify the APK was built from HEAD, the tag matches versionName, apksigner passes, and the tree is clean; publish a sha256 sidecar and treat published assets as immutable. Compare full unsigned APKs in verify-repro and run the Android unit tests in CI. --- ci/release.sh | 88 +++++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 70 insertions(+), 18 deletions(-) (limited to 'ci/release.sh') diff --git a/ci/release.sh b/ci/release.sh index 260efba..1c96d1b 100755 --- a/ci/release.sh +++ b/ci/release.sh @@ -14,38 +14,90 @@ set -eu : "${CODEBERG_REPO:?set CODEBERG_REPO as owner/name}" tag=${TAG:-${CI_COMMIT_TAG:?set TAG or CI_COMMIT_TAG}} base=${CODEBERG_URL:-https://codeberg.org} -apk=app/build/outputs/apk/release/app-release.apk +base=${base%/} +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +apk="$root/app/build/outputs/apk/release/app-release.apk" +source_commit="$root/app/build/outputs/apk/release/.source-commit" [ -f "$apk" ] || { echo "release: $apk not found; run ci/build.sh first" >&2; exit 1; } +[ -f "$source_commit" ] || { echo "release: build provenance not found; run ci/build.sh first" >&2; exit 1; } +command -v apksigner >/dev/null 2>&1 || { echo "release: apksigner is required" >&2; exit 1; } +apksigner verify --verbose --print-certs "$apk" + +[ -z "$(git -C "$root" status --porcelain --untracked-files=normal)" ] || { + echo "release: working tree is not clean" >&2 + exit 1 +} + +case $CODEBERG_REPO in + *[!A-Za-z0-9._/-]*|*/*/*|/*|*/) + echo "release: invalid CODEBERG_REPO" >&2 + exit 1 + ;; + */*) ;; + *) + echo "release: CODEBERG_REPO must be owner/name" >&2 + exit 1 + ;; +esac + +head=$(git -C "$root" rev-parse HEAD) +IFS= read -r built_commit < "$source_commit" +[ "$built_commit" = "$head" ] || { echo "release: APK was not built from HEAD" >&2; exit 1; } +git check-ref-format "refs/tags/$tag" >/dev/null 2>&1 || { echo "release: invalid tag $tag" >&2; exit 1; } +tag_head=$(git -C "$root" rev-parse --verify "$tag^{commit}") +[ "$head" = "$tag_head" ] || { echo "release: $tag does not point to HEAD" >&2; exit 1; } +version=$(python3 -c 'import re,sys +s=open(sys.argv[1]).read() +m=re.search(r"^\s*versionName [\x27\x22]([^\x27\x22]+)", s, re.M) +print(m.group(1) if m else "")' "$root/app/build.gradle") +[ "$tag" = "$version" ] || { echo "release: tag $tag does not match app version $version" >&2; exit 1; } + +quote() { + python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1" +} api="$base/api/v1/repos/$CODEBERG_REPO" auth="Authorization: token $CODEBERG_TOKEN" name="rsend-$tag.apk" +sum_name="$name.sha256" +tag_url=$(quote "$tag") +name_url=$(quote "$name") +sum_name_url=$(quote "$sum_name") +sum=$(mktemp) +trap 'rm -f "$sum"' 0 +sha256sum "$apk" | { read -r hash _; printf '%s %s\n' "$hash" "$name"; } > "$sum" -# Reuse the tag's release if it exists, else create it. Re-runs are idempotent: -# same release, and a stale asset from an earlier run is replaced below. -id=$(curl -fsS -H "$auth" "$api/releases/tags/$tag" 2>/dev/null \ +# Reuse the tag's release if it exists, else create it. Published assets are +# immutable: a rerun uploads only missing assets and never replaces one. +id=$(curl -fsS -H "$auth" "$api/releases/tags/$tag_url" 2>/dev/null \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null) || id= if [ -n "$id" ]; then - curl -fsS -H "$auth" "$api/releases/$id/assets" \ - | python3 -c 'import json,sys -for a in json.load(sys.stdin): - if a["name"] == sys.argv[1]: - print(a["id"])' "$name" \ - | while read -r aid; do - curl -fsS -X DELETE -H "$auth" "$api/releases/$id/assets/$aid" - done + assets=$(curl -fsS -H "$auth" "$api/releases/$id/assets") else + payload=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"name":sys.argv[1]}))' "$tag") id=$(curl -fsS -X POST "$api/releases" \ -H "$auth" -H "Content-Type: application/json" \ - -d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \ + -d "$payload" \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') fi [ -n "$id" ] || { echo "release: could not create release for $tag" >&2; exit 1; } -# Upload the APK as a release asset. -curl -fsS -X POST "$api/releases/$id/assets?name=$name" \ - -H "$auth" \ - -F "attachment=@$apk" >/dev/null +has_asset() { + printf '%s' "${assets:-[]}" | python3 -c 'import json,sys +name=sys.argv[1] +raise SystemExit(0 if any(a["name"] == name for a in json.load(sys.stdin)) else 1)' "$1" +} + +if ! has_asset "$name"; then + curl -fsS -X POST "$api/releases/$id/assets?name=$name_url" \ + -H "$auth" \ + -F "attachment=@$apk" >/dev/null +fi +if ! has_asset "$sum_name"; then + curl -fsS -X POST "$api/releases/$id/assets?name=$sum_name_url" \ + -H "$auth" \ + -F "attachment=@$sum" >/dev/null +fi -echo "release: uploaded $name to $CODEBERG_REPO ($tag)" +echo "release: $name and $sum_name are present on $CODEBERG_REPO ($tag)" -- cgit v1.2.3