aboutsummaryrefslogtreecommitdiff
path: root/ci/release.sh
diff options
context:
space:
mode:
authorLena <lena@omega>2026-07-01 00:00:00 +0000
committerLena <lena@omega>2026-07-13 22:00:29 +0000
commit2aa6920fb1568249d2466fa1b13760d1a51c0e8f (patch)
tree5cd196d2017228cac43f2574d559d9a59b123a47 /ci/release.sh
parent2593151ca2c67256442ad25b074504e3ecd3371f (diff)
downloadrsend-2aa6920fb1568249d2466fa1b13760d1a51c0e8f.tar.gz
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.
Diffstat (limited to 'ci/release.sh')
-rwxr-xr-xci/release.sh88
1 files changed, 70 insertions, 18 deletions
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)"