All checks were successful
Android CI / build (push) Successful in 21m45s
Over DPI-throttled MTProto the bot often finishes the APK-album upload after the client has already hit its read timeout, so a non-zero publisher exit is not proof of failure. On a publisher error the local flow now waits a bounded grace period and trusts the bot's publish ledger (zapretkvn_publisher_state.json) — if the album is recorded for this tag it is treated as published (marker written), and only a genuinely missing album fails the release. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
338 lines
14 KiB
Shell
Executable file
338 lines
14 KiB
Shell
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
TAG="${1:-}"
|
|
APPROVAL="${2:-}"
|
|
SIGNING_DIR="${ZAPRET_SIGNING_DIR:-${HOME:?HOME is required}/.zapret-kvn-signing}"
|
|
SIGNING_ENV="${ZAPRET_SIGNING_ENV:-$SIGNING_DIR/signing-secrets.env}"
|
|
if [[ ! -f "$SIGNING_ENV" && -f "$SIGNING_DIR/github-secrets.env" ]]; then
|
|
SIGNING_ENV="$SIGNING_DIR/github-secrets.env"
|
|
fi
|
|
SIGNING_STORE="$SIGNING_DIR/zapret-kvn-release.jks"
|
|
SIGNING_FINGERPRINT_FILE="$SIGNING_DIR/certificate-sha256.txt"
|
|
RELEASE_MATRIX_DIR="$PROJECT_ROOT/app/build/outputs/apk/matrix/release"
|
|
OUTPUT_DIR="$PROJECT_ROOT/build/local-release/$TAG"
|
|
STAGING_OUTPUT_DIR="$PROJECT_ROOT/build/local-release/.$TAG.staging"
|
|
RELEASE_REPOSITORY="${ZAPRET_UPDATE_REPOSITORY:-zapretkvn/ZapretKVN-android}"
|
|
FORGEJO_URL="${ZAPRET_FORGEJO_URL:-https://git.zapret.moe}"
|
|
FORGEJO_TOKEN_FILE="${ZAPRET_FORGEJO_TOKEN_FILE:-${HOME:?HOME is required}/.config/forgejo/zapret-kvn-android-release-token}"
|
|
# Single source of truth for the user-facing changelog, shared with the Forgejo
|
|
# release notes (create-release-bundle.sh).
|
|
RELEASE_CHANGES_FILE="${ZAPRET_RELEASE_CHANGES_FILE:-$PROJECT_ROOT/scripts/release-changes.txt}"
|
|
# Host-local ZapretGPT delivery helper that posts the Telegram announcement.
|
|
TELEGRAM_PUBLISHER="${ZAPRET_KVN_TG_PUBLISHER:-/home/codex-pve/zapretgpt/scripts/publish_zapretkvn_stable.py}"
|
|
# Bot-owned publish ledger; consulted after a publisher timeout to tell a false
|
|
# client timeout (the bot finished the upload anyway) from a real failure.
|
|
TELEGRAM_PUBLISHER_STATE="${ZAPRET_KVN_TG_STATE:-/home/codex-pve/zapretgpt/data/zapretkvn_publisher_state.json}"
|
|
|
|
# Succeeds when the bot ledger already records an Android artifact for this tag.
|
|
telegram_album_recorded() {
|
|
python3 - "$TELEGRAM_PUBLISHER_STATE" "android|$TAG|" <<'PY'
|
|
import json, sys
|
|
path, prefix = sys.argv[1], sys.argv[2]
|
|
try:
|
|
with open(path, encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
except (OSError, ValueError):
|
|
sys.exit(1)
|
|
published = data.get("published", data)
|
|
keys = published.keys() if isinstance(published, dict) else published
|
|
sys.exit(0 if any(isinstance(k, str) and k.startswith(prefix) for k in keys) else 1)
|
|
PY
|
|
}
|
|
|
|
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ || "$APPROVAL" != --final-gate-approved ]]; then
|
|
echo "Usage: $0 vMAJOR.MINOR.PATCH --final-gate-approved" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# This release pair was resolved once before source validation/commit. Dev,
|
|
# stable and resume all consume the same pin without selecting upstream again.
|
|
python3 "$PROJECT_ROOT/scripts/check_core_release_freeze.py" android "$TAG" --root "$PROJECT_ROOT"
|
|
|
|
for command in curl git jq keytool mv sha256sum stat timeout; do
|
|
command -v "$command" >/dev/null || {
|
|
echo "Missing required command: $command" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
if [[ -n "${ZAPRET_FORGEJO_TOKEN:-}" ]]; then
|
|
FORGEJO_TOKEN="$ZAPRET_FORGEJO_TOKEN"
|
|
else
|
|
if [[ ! -f "$FORGEJO_TOKEN_FILE" || "$(stat -c '%a' "$FORGEJO_TOKEN_FILE")" != 600 ]]; then
|
|
echo "Forgejo token file must exist with mode 600: $FORGEJO_TOKEN_FILE" >&2
|
|
exit 1
|
|
fi
|
|
IFS= read -r FORGEJO_TOKEN < "$FORGEJO_TOKEN_FILE"
|
|
fi
|
|
if [[ -z "$FORGEJO_TOKEN" ]]; then
|
|
echo "Forgejo token is empty" >&2
|
|
exit 1
|
|
fi
|
|
RELEASE_PROBE="$(mktemp)"
|
|
trap 'rm -f -- "$RELEASE_PROBE"' EXIT
|
|
forgejo_api() {
|
|
curl --silent --show-error --fail \
|
|
--connect-timeout 20 --max-time 120 \
|
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
-H 'Accept: application/json' \
|
|
"$@"
|
|
}
|
|
if [[ "$(stat -c '%a' "$SIGNING_DIR")" != 700 ]]; then
|
|
echo "Signing directory must have mode 700: $SIGNING_DIR" >&2
|
|
exit 1
|
|
fi
|
|
for private_file in "$SIGNING_ENV" "$SIGNING_STORE" "$SIGNING_FINGERPRINT_FILE"; do
|
|
if [[ ! -f "$private_file" || "$(stat -c '%a' "$private_file")" != 600 ]]; then
|
|
echo "Signing file must exist with mode 600: $private_file" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
cd "$PROJECT_ROOT"
|
|
if [[ "$(git branch --show-current)" != main ]]; then
|
|
echo "Stable releases must be published directly from main" >&2
|
|
exit 1
|
|
fi
|
|
if ! git diff --quiet || ! git diff --cached --quiet ||
|
|
[[ -n "$(git ls-files --others --exclude-standard)" ]]; then
|
|
echo "Stable releases require a clean worktree" >&2
|
|
exit 1
|
|
fi
|
|
|
|
git fetch origin main
|
|
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
|
|
echo "Local main must exactly match origin/main before publication" >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$(git remote get-url origin)" != *"/$RELEASE_REPOSITORY.git" ]]; then
|
|
echo "Current repository does not match the updater release repository" >&2
|
|
exit 1
|
|
fi
|
|
if ! git show-ref --verify --quiet "refs/tags/$TAG"; then
|
|
echo "Create the stable tag on the release commit before publication: $TAG" >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$(git rev-list -n 1 "$TAG")" != "$(git rev-parse HEAD)" ]]; then
|
|
echo "Stable tag must point to the current main commit: $TAG" >&2
|
|
exit 1
|
|
fi
|
|
|
|
remote_tag_commit="$(
|
|
{
|
|
git ls-remote origin "refs/tags/$TAG^{}"
|
|
git ls-remote origin "refs/tags/$TAG"
|
|
} | awk 'NR == 1 {print $1}'
|
|
)"
|
|
if [[ -z "$remote_tag_commit" ]]; then
|
|
git push origin "refs/tags/$TAG"
|
|
elif [[ "$remote_tag_commit" != "$(git rev-parse HEAD)" ]]; then
|
|
echo "Remote tag points to a different commit: $TAG" >&2
|
|
exit 1
|
|
fi
|
|
|
|
release_http="$(curl --silent --show-error \
|
|
--connect-timeout 20 --max-time 120 \
|
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
-H 'Accept: application/json' \
|
|
--output "$RELEASE_PROBE" \
|
|
--write-out '%{http_code}' \
|
|
"$FORGEJO_URL/api/v1/repos/$RELEASE_REPOSITORY/releases/tags/$TAG")"
|
|
if [[ "$release_http" == 200 ]]; then
|
|
release_json="$(jq -ce . "$RELEASE_PROBE")"
|
|
if ! jq -e \
|
|
--arg tag "$TAG" \
|
|
'.tag_name == $tag and .draft == true and .prerelease == false' \
|
|
<<<"$release_json" >/dev/null; then
|
|
echo "Forgejo Release already exists and is not a resumable draft: $TAG" >&2
|
|
exit 1
|
|
fi
|
|
if [[ ! -d "$OUTPUT_DIR" ]]; then
|
|
echo "Stable draft exists, but its original local bundle is unavailable: $OUTPUT_DIR" >&2
|
|
echo "Refusing to rebuild potentially different assets or alter the draft." >&2
|
|
exit 1
|
|
fi
|
|
echo "Found resumable stable draft: $TAG"
|
|
elif [[ "$release_http" != 404 ]]; then
|
|
echo "Forgejo release probe failed with HTTP $release_http" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# This file is owner-only and is the existing canonical local copy of the release
|
|
# credentials. Parse assignments as data so the credentials file is never executed.
|
|
declare -A signing_values=()
|
|
while IFS='=' read -r name value; do
|
|
case "$name" in
|
|
ANDROID_SIGNING_KEYSTORE_BASE64|ANDROID_SIGNING_STORE_PASSWORD|ANDROID_SIGNING_KEY_ALIAS|ANDROID_SIGNING_KEY_PASSWORD|ANDROID_SIGNING_CERT_SHA256)
|
|
if [[ -v "signing_values[$name]" ]]; then
|
|
echo "Duplicate signing variable: $name" >&2
|
|
exit 1
|
|
fi
|
|
signing_values["$name"]="$value"
|
|
;;
|
|
*)
|
|
echo "Unexpected entry in signing credentials file: $name" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done < "$SIGNING_ENV"
|
|
ANDROID_SIGNING_STORE_PASSWORD="${signing_values[ANDROID_SIGNING_STORE_PASSWORD]:-}"
|
|
ANDROID_SIGNING_KEY_ALIAS="${signing_values[ANDROID_SIGNING_KEY_ALIAS]:-}"
|
|
ANDROID_SIGNING_KEY_PASSWORD="${signing_values[ANDROID_SIGNING_KEY_PASSWORD]:-}"
|
|
ANDROID_SIGNING_CERT_SHA256="${signing_values[ANDROID_SIGNING_CERT_SHA256]:-}"
|
|
: "${ANDROID_SIGNING_STORE_PASSWORD:?Missing signing store password}"
|
|
: "${ANDROID_SIGNING_KEY_ALIAS:?Missing signing key alias}"
|
|
: "${ANDROID_SIGNING_KEY_PASSWORD:?Missing signing key password}"
|
|
: "${ANDROID_SIGNING_CERT_SHA256:?Missing signing certificate fingerprint}"
|
|
|
|
# shellcheck disable=SC1091
|
|
source "$PROJECT_ROOT/release.properties"
|
|
private_fingerprint="$(
|
|
tr '[:upper:]' '[:lower:]' < "$SIGNING_FINGERPRINT_FILE" | tr -d ':[:space:]'
|
|
)"
|
|
configured_fingerprint="$(
|
|
tr '[:upper:]' '[:lower:]' <<<"$ANDROID_SIGNING_CERT_SHA256" | tr -d ':[:space:]'
|
|
)"
|
|
export ZAPRET_SIGNING_STORE_PASSWORD="$ANDROID_SIGNING_STORE_PASSWORD"
|
|
jks_fingerprint="$(
|
|
keytool -exportcert \
|
|
-keystore "$SIGNING_STORE" \
|
|
-alias "$ANDROID_SIGNING_KEY_ALIAS" \
|
|
-storepass:env ZAPRET_SIGNING_STORE_PASSWORD \
|
|
| sha256sum \
|
|
| awk '{print $1}'
|
|
)"
|
|
if [[ ! "$RELEASE_SIGNER_SHA256" =~ ^[0-9a-f]{64}$ ]] ||
|
|
[[ "$private_fingerprint" != "$RELEASE_SIGNER_SHA256" ]] ||
|
|
[[ "$configured_fingerprint" != "$RELEASE_SIGNER_SHA256" ]] ||
|
|
[[ "$jks_fingerprint" != "$RELEASE_SIGNER_SHA256" ]]; then
|
|
echo "Production signing certificate does not match release.properties" >&2
|
|
exit 1
|
|
fi
|
|
|
|
while IFS='=' read -r name value; do
|
|
case "$name" in
|
|
ZAPRET_VERSION_NAME|ZAPRET_VERSION_CODE|ZAPRET_PRERELEASE|ZAPRET_RELEASE_CHANNEL)
|
|
export "$name=$value"
|
|
;;
|
|
*)
|
|
echo "Unexpected release variable: $name" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done < <("$PROJECT_ROOT/scripts/derive-release-version.sh" "$TAG")
|
|
if [[ "$ZAPRET_RELEASE_CHANNEL" != stable || "$ZAPRET_PRERELEASE" != false ]]; then
|
|
echo "Local publisher accepts stable tags only" >&2
|
|
exit 1
|
|
fi
|
|
|
|
latest_tag="$(forgejo_api "$FORGEJO_URL/api/v1/repos/$RELEASE_REPOSITORY/releases/latest" 2>/dev/null | jq -r '.tag_name // empty' || true)"
|
|
if [[ -n "$latest_tag" ]]; then
|
|
latest_version_code="$(
|
|
"$PROJECT_ROOT/scripts/derive-release-version.sh" "$latest_tag" \
|
|
| sed -n 's/^ZAPRET_VERSION_CODE=//p'
|
|
)"
|
|
if (( ZAPRET_VERSION_CODE <= latest_version_code )); then
|
|
echo "Stable versionCode must be newer than the latest published stable" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
export ZAPRET_SIGNING_STORE_FILE="$SIGNING_STORE"
|
|
export ZAPRET_SIGNING_KEY_ALIAS="$ANDROID_SIGNING_KEY_ALIAS"
|
|
export ZAPRET_SIGNING_KEY_PASSWORD="$ANDROID_SIGNING_KEY_PASSWORD"
|
|
export ZAPRET_EXPECTED_SIGNER_SHA256="$RELEASE_SIGNER_SHA256"
|
|
export ZAPRET_REQUIRE_SIGNED_RELEASE=1
|
|
export ZAPRET_UPDATE_REPOSITORY="$RELEASE_REPOSITORY"
|
|
|
|
if [[ -e "$OUTPUT_DIR" && ! -d "$OUTPUT_DIR" ]]; then
|
|
echo "Local release output path is not a directory: $OUTPUT_DIR" >&2
|
|
exit 1
|
|
fi
|
|
if [[ -d "$OUTPUT_DIR" ]]; then
|
|
echo "Reusing existing local release bundle after full verification: $OUTPUT_DIR"
|
|
"$PROJECT_ROOT/scripts/verify-release-bundle.sh" "$TAG" "$OUTPUT_DIR"
|
|
else
|
|
if [[ -e "$STAGING_OUTPUT_DIR" ]]; then
|
|
echo "Incomplete staging output exists; move it aside before retrying: $STAGING_OUTPUT_DIR" >&2
|
|
exit 1
|
|
fi
|
|
"$PROJECT_ROOT/scripts/ci-build.sh"
|
|
"$PROJECT_ROOT/scripts/create-release-bundle.sh" \
|
|
"$TAG" \
|
|
"$RELEASE_MATRIX_DIR" \
|
|
"$STAGING_OUTPUT_DIR"
|
|
"$PROJECT_ROOT/scripts/verify-release-bundle.sh" "$TAG" "$STAGING_OUTPUT_DIR"
|
|
mv "$STAGING_OUTPUT_DIR" "$OUTPUT_DIR"
|
|
echo "Promoted verified release bundle atomically: $OUTPUT_DIR"
|
|
fi
|
|
|
|
"$PROJECT_ROOT/scripts/publish-forgejo-stable.sh" \
|
|
"$TAG" \
|
|
"$OUTPUT_DIR" \
|
|
"$RELEASE_REPOSITORY"
|
|
|
|
# Telegram announcement is the mandatory final step of a stable release (unless
|
|
# explicitly skipped): the Forgejo release is now visible, so announce it once
|
|
# through the host-local ZapretGPT bot using the SAME changelog the Forgejo notes
|
|
# were built from. Idempotent via a per-bundle marker so a resumed publish does
|
|
# not double-post.
|
|
telegram_marker="$OUTPUT_DIR/.telegram-published"
|
|
if [[ "${ZAPRET_SKIP_TELEGRAM:-0}" == 1 ]]; then
|
|
echo "Skipping Telegram announcement (ZAPRET_SKIP_TELEGRAM=1)."
|
|
elif [[ -f "$telegram_marker" ]]; then
|
|
echo "Telegram announcement already sent for $TAG; skipping."
|
|
else
|
|
[[ -f "$RELEASE_CHANGES_FILE" ]] || {
|
|
echo "Missing changelog source: $RELEASE_CHANGES_FILE" >&2
|
|
exit 1
|
|
}
|
|
[[ -f "$TELEGRAM_PUBLISHER" ]] || {
|
|
echo "Telegram publisher not found: $TELEGRAM_PUBLISHER" >&2
|
|
exit 1
|
|
}
|
|
telegram_changes=()
|
|
while IFS= read -r change_line || [[ -n "$change_line" ]]; do
|
|
[[ -z "${change_line// }" || "$change_line" == \#* ]] && continue
|
|
telegram_changes+=(--change "${change_line# }")
|
|
done < "$RELEASE_CHANGES_FILE"
|
|
if (( ${#telegram_changes[@]} == 0 )); then
|
|
echo "No changelog items to announce in $RELEASE_CHANGES_FILE" >&2
|
|
exit 1
|
|
fi
|
|
if python3 "$TELEGRAM_PUBLISHER" android "${TAG#v}" "${telegram_changes[@]}"; then
|
|
: > "$telegram_marker"
|
|
echo "Telegram announcement published for $TAG."
|
|
else
|
|
# A client-side timeout is not proof of failure: over DPI-throttled MTProto
|
|
# the bot often finishes the upload after the client gives up. Give it a
|
|
# bounded grace period and trust the bot's publish ledger before failing.
|
|
echo "Telegram publisher exited non-zero; checking whether the album landed anyway…" >&2
|
|
for _ in $(seq 1 20); do
|
|
if telegram_album_recorded; then
|
|
: > "$telegram_marker"
|
|
echo "Telegram album for $TAG confirmed in the bot ledger despite the client timeout."
|
|
break
|
|
fi
|
|
sleep 30
|
|
done
|
|
if [[ ! -f "$telegram_marker" ]]; then
|
|
echo "Stable $TAG published, but the Telegram announcement failed; re-run to retry." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
dispatch_payload="$(jq -n --arg tag "$TAG" '{ref:"main",inputs:{tag:$tag}}')"
|
|
if forgejo_api \
|
|
--request POST \
|
|
-H 'Content-Type: application/json' \
|
|
--data "$dispatch_payload" \
|
|
"$FORGEJO_URL/api/v1/repos/$RELEASE_REPOSITORY/actions/workflows/release-verify.yml/dispatches" \
|
|
>/dev/null; then
|
|
echo "Stable $TAG published; independent Forgejo Actions verification was dispatched."
|
|
else
|
|
echo "Stable $TAG was published, but background Forgejo verification dispatch failed." >&2
|
|
exit 1
|
|
fi
|