ZapretKVN-android/scripts/publish-local-stable.sh
loop-uh 21effd732c
All checks were successful
Android CI / build (push) Successful in 21m45s
release: verify the Telegram album landed before failing on a publisher timeout
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>
2026-09-14 17:59:30 +03:00

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