Revert "ci: post macOS/Linux/Windows/Android binaries as Telegram media group"

This reverts commit e9ce03e697.
This commit is contained in:
therealaleph
2026-04-26 21:10:50 +03:00
parent b18d9ab604
commit e81974c204
2 changed files with 62 additions and 428 deletions
+35 -277
View File
@@ -202,81 +202,6 @@ def sha256_of(path: str) -> str:
return h.hexdigest()
def build_media_group_caption(files: list, version: str, repo: str) -> str:
"""Build the single shared caption for a media-group post.
Caption shape (each file is one filename + one SHA line):
<b>mhrv-rs v1.7.1</b>
<b>mhrv-rs-linux-amd64-v1.7.1</b>
<code>{sha256}</code>
<b>mhrv-rs-windows-amd64-v1.7.1.exe</b>
<code>{sha256}</code>
...
مخزن گیتهاب + مطالعه راهنمای کامل فارسی:
https://github.com/{repo}
لینک به این نسخه:
https://github.com/{repo}/releases/tag/v{version}
Telegram caption hard-cap is 1024 chars. A typical 6-file release
fits comfortably (~860 chars); the budget check at the bottom is a
safety net for edge cases (longer filename suffixes, extra files).
The release-note `<blockquote>` block that the single-document path
renders does NOT belong here — with N files in the group the SHA
list eats the caption budget, and the release-note bullets move to
the reply-threaded changelog message instead (sent unconditionally
when sending a media group, since there's nowhere else for them).
"""
lines: list = [f"<b>mhrv-rs v{version}</b>", ""]
for path in files:
name = os.path.basename(path)
sha = sha256_of(path)
lines.append(f"<b>{name}</b>")
lines.append(f"<code>{sha}</code>")
lines.append("")
lines.extend([
"مخزن گیتهاب + مطالعه راهنمای کامل فارسی:",
f"https://github.com/{repo}",
"",
"لینک به این نسخه:",
f"https://github.com/{repo}/releases/tag/v{version}",
])
caption = "\n".join(lines)
if len(caption) > 1024:
# Truncate from the SHA list — header + footer URLs must stay.
# In practice we never hit this with the current 4-platform
# release; this branch is a guard for "what if we add 5 more
# ABIs later" so the caller doesn't silently fail Telegram's
# cap rejection. Falling back to "list filenames only, drop
# SHAs" keeps the post useful while flagging the issue in CI.
print(
f"::warning::caption {len(caption)} chars > 1024; "
f"falling back to filename-only list",
file=sys.stderr,
)
compact: list = [f"<b>mhrv-rs v{version}</b>", ""]
for path in files:
compact.append(f"• <code>{os.path.basename(path)}</code>")
compact.extend([
"",
"(SHA-256 hashes truncated; see GitHub release page.)",
"",
"مخزن گیتهاب:",
f"https://github.com/{repo}",
"",
"این نسخه:",
f"https://github.com/{repo}/releases/tag/v{version}",
])
caption = "\n".join(compact)
return caption
def tg_request(method: str, token: str, *, body: bytes, content_type: str) -> dict:
"""POST `body` to https://api.telegram.org/bot<token>/<method>."""
conn = http.client.HTTPSConnection(
@@ -299,30 +224,10 @@ def tg_request(method: str, token: str, *, body: bytes, content_type: str) -> di
return data["result"]
def _content_type_for(path: str) -> str:
"""Pick a sensible Content-Type so Telegram clients label the file
with the right icon/extension hint. APKs get the Android-specific
MIME so the channel preview shows the Android package icon; raw
desktop binaries (Mach-O / ELF / PE) and tarballs fall through to
octet-stream — Telegram still displays them with the user-supplied
filename and downloads correctly.
"""
ext = os.path.splitext(path)[1].lower()
if ext == ".apk":
return "application/vnd.android.package-archive"
return "application/octet-stream"
def send_document(token: str, chat_id: str, file_path: str, caption: str) -> int:
"""Upload a single file with a short HTML caption. Returns message_id.
Used for single-file releases (e.g. APK-only) and as the fallback
when --files is passed exactly one path. Multi-file releases go
through send_media_group instead — Telegram's sendDocument can only
upload one file per call.
"""
def send_document(token: str, chat_id: str, apk_path: str, caption: str) -> int:
"""Upload the APK file with a short HTML caption. Returns message_id."""
boundary = "----" + uuid.uuid4().hex
with open(file_path, "rb") as f:
with open(apk_path, "rb") as f:
file_bytes = f.read()
def text_field(name: str, value: str) -> bytes:
@@ -332,11 +237,13 @@ def send_document(token: str, chat_id: str, file_path: str, caption: str) -> int
f"{value}\r\n"
).encode("utf-8")
def file_field(name: str, filename: str, content: bytes, content_type: str) -> bytes:
def file_field(name: str, filename: str, content: bytes) -> bytes:
head = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
# Proper MIME type — makes the Telegram client show the APK
# with the Android package icon and honour its size/name.
f"Content-Type: application/vnd.android.package-archive\r\n\r\n"
).encode("utf-8")
return head + content + b"\r\n"
@@ -344,12 +251,7 @@ def send_document(token: str, chat_id: str, file_path: str, caption: str) -> int
text_field("chat_id", chat_id)
+ text_field("caption", caption)
+ text_field("parse_mode", "HTML")
+ file_field(
"document",
os.path.basename(file_path),
file_bytes,
_content_type_for(file_path),
)
+ file_field("document", os.path.basename(apk_path), file_bytes)
+ f"--{boundary}--\r\n".encode("utf-8")
)
@@ -362,86 +264,6 @@ def send_document(token: str, chat_id: str, file_path: str, caption: str) -> int
return int(result["message_id"])
def send_media_group(
token: str, chat_id: str, file_paths: list, caption: str
) -> int:
"""Upload 210 files as a single Telegram media group. Returns the
message_id of the first message in the group (which is what any
threaded reply should reference).
Telegram quirks:
- `sendMediaGroup` accepts 2..=10 items. Zero/one is rejected;
eleven+ is rejected. Caller must pre-check.
- The `caption` field on `InputMediaDocument` is shown only when
attached to the FIRST item in the group — Telegram clients
render that caption above the document stack. Captions on
later items in the group are silently dropped by some clients.
We attach the caption to file 0 and leave the rest captionless.
- `media` parameter is a JSON-encoded array; each item references
its file via `attach://<form-data-name>`. We use `file0`,
`file1`, ... for clarity in case Telegram ever surfaces the
multipart name in error responses.
- The total bytes of all files in a single media group must fit
in Telegram's per-request limit (50 MB for bot uploads). For
our typical release (~6 files × ~515 MB each) we're well
under, but a future Android APK swell could hit this — caller
should split into multiple groups in that case.
"""
if len(file_paths) < 2 or len(file_paths) > 10:
raise SystemExit(
f"send_media_group: need 2..=10 files, got {len(file_paths)}"
)
boundary = "----" + uuid.uuid4().hex
media_array = []
for i, path in enumerate(file_paths):
item = {"type": "document", "media": f"attach://file{i}"}
if i == 0:
# Caption on the first item only — see docstring.
item["caption"] = caption
item["parse_mode"] = "HTML"
media_array.append(item)
parts: list = []
def text_field(name: str, value: str) -> bytes:
return (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
f"{value}\r\n"
).encode("utf-8")
parts.append(text_field("chat_id", chat_id))
parts.append(text_field("media", json.dumps(media_array)))
for i, path in enumerate(file_paths):
with open(path, "rb") as f:
content = f.read()
head = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file{i}"; '
f'filename="{os.path.basename(path)}"\r\n'
f"Content-Type: {_content_type_for(path)}\r\n\r\n"
).encode("utf-8")
parts.append(head + content + b"\r\n")
parts.append(f"--{boundary}--\r\n".encode("utf-8"))
body = b"".join(parts)
# sendMediaGroup returns an array of Message objects (one per item);
# caller reply-threading targets the first message.
result = tg_request(
"sendMediaGroup",
token,
body=body,
content_type=f"multipart/form-data; boundary={boundary}",
)
if not isinstance(result, list) or not result:
raise SystemExit(f"sendMediaGroup: unexpected response shape: {result!r}")
return int(result[0]["message_id"])
def send_reply(token: str, chat_id: str, text: str, reply_to: int) -> None:
"""Post a text message as a reply to the APK message."""
from urllib.parse import urlencode
@@ -464,51 +286,27 @@ def send_reply(token: str, chat_id: str, text: str, reply_to: int) -> None:
def main() -> int:
ap = argparse.ArgumentParser()
# Two ways to specify what to send:
# --files <path> [--files <path> ...] (preferred, multi-platform)
# Sends the files as a single Telegram media group with one
# caption listing each filename + SHA-256. The follow-up changelog
# reply is automatic for media-group posts (the FA bullet block
# would normally live in the caption, but the per-file SHA list
# eats that budget — see build_media_group_caption docstring).
# --apk <path> (legacy, single file)
# Sends one document with the original caption layout (title +
# single SHA + brief FA note + two link rows). Reply with
# changelog is gated on --with-changelog as before.
# Exactly one of the two must be present; if --files is given multiple
# times we use the media-group path even if --apk is also given.
ap.add_argument("--apk", required=False,
help="Single file to send via sendDocument (legacy). "
"Prefer --files for new releases.")
ap.add_argument("--files", action="append", default=[],
help="Path to a release file. Pass once per file; "
"2..=10 files are sent as a Telegram media group "
"(one caption listing all filenames + SHA-256).")
ap.add_argument("--apk", required=True)
ap.add_argument("--version", required=True)
ap.add_argument("--repo", required=True)
ap.add_argument("--changelog", required=True,
help="Path to docs/changelog/vX.Y.Z.md; read for the "
"FA brief-note in the legacy caption (--apk path) "
"and for the reply-threaded changelog message.")
# Default for --apk path: just the APK + short caption.
# For --files path: the FA+EN reply is automatic since the caption is
# full of SHA hashes; toggle is ignored in that case.
help="Path to docs/changelog/vX.Y.Z.md; only read when --with-changelog is passed.")
# Default: just the APK + short caption (title + SHA-256 + repo URL +
# release URL). The per-release Persian/English blockquote reply is
# opt-in via `--with-changelog` so routine releases don't flood the
# channel with bullet-point bodies. To re-enable for a specific tag:
# set the repo variable TELEGRAM_INCLUDE_CHANGELOG=true before pushing
# the tag (the workflow converts that into --with-changelog).
ap.add_argument("--with-changelog", action="store_true",
help="(--apk path only) Include the Persian+English "
"changelog as a reply-threaded message. Ignored "
"with --files: media group always replies with "
"the changelog because the per-file SHA list "
"leaves no caption room for the FA brief-note.")
help="Include the Persian+English changelog as a reply-threaded message.")
# Dry-run lets you verify the rendered caption locally without hitting
# Telegram. Useful when changing caption layout — print, eyeball, push.
# Telegram. Useful when changing the brief-release-note budget /
# truncation logic — print, eyeball, push.
ap.add_argument("--dry-run", action="store_true",
help="Render the caption and print it instead of posting. "
"Skips token/chat_id checks.")
args = ap.parse_args()
if not args.files and not args.apk:
ap.error("either --apk or --files is required")
if not args.dry_run:
token = os.environ.get("BOT_TOKEN", "")
chat_id = os.environ.get("CHAT_ID", "")
@@ -520,64 +318,24 @@ def main() -> int:
chat_id = ""
ver = args.version
# ------------------------------------------------------------------
# Multi-file path (media group)
# ------------------------------------------------------------------
if args.files:
files = list(args.files)
if len(files) == 1:
# Telegram's sendMediaGroup rejects single-item groups, so
# one-file --files calls fall through to sendDocument with
# the same multi-file caption shape (still has the SHA list
# below the title). This makes --files a clean superset of
# --apk semantically: callers can pass 1..=10 files without
# branching on platform-specific build outputs.
caption = build_media_group_caption(files, ver, args.repo)
if args.dry_run:
print(f"--- DRY RUN: single-file caption ({len(caption)} chars) ---")
print(caption)
return 0
mid = send_document(token, chat_id, files[0], caption)
print(f"sendDocument OK, message_id={mid}")
else:
caption = build_media_group_caption(files, ver, args.repo)
if args.dry_run:
print(f"--- DRY RUN: media-group caption ({len(caption)} chars) ---")
print(caption)
print(f"--- {len(files)} files would be uploaded ---")
for f in files:
print(f" {os.path.basename(f)}")
return 0
mid = send_media_group(token, chat_id, files, caption)
print(f"sendMediaGroup OK ({len(files)} files), first message_id={mid}")
# Always reply with the changelog when sending a media group —
# the per-file SHA list pushed the FA bullet headlines out of
# the caption, so the reply is the only place they fit.
# Single-file --files calls also reply, which matches the
# multi-file behaviour and avoids surprising users who switch
# back and forth between 1-file and N-file releases.
fa, en = parse_changelog(args.changelog)
if not fa and not en:
print(f"No changelog at {args.changelog}, skipping reply.")
return 0
reply_parts: list = []
if fa:
reply_parts.append(f"<blockquote>{fa}</blockquote>")
if en:
reply_parts.append(f"<blockquote>{en}</blockquote>")
send_reply(token, chat_id, "\n\n".join(reply_parts), mid)
print("Reply OK")
return 0
# ------------------------------------------------------------------
# Single-file path (legacy --apk, kept for any caller that hasn't
# migrated to --files yet).
# ------------------------------------------------------------------
sha = sha256_of(args.apk)
# Brief Persian release-note above the links. Pulled from the FA
# half of `docs/changelog/v<ver>.md` so each release auto-includes
# what's new without manual edits to this script. Truncated to fit
# Telegram's 1024-char caption budget alongside title + SHA + the
# two-link footer.
fa_note = build_caption_release_note(args.changelog)
# Caption structure requested by the repo owner:
# 1. Title + SHA-256 (as before)
# 2. Brief Persian "what's new" note (extracted from changelog)
# 3. Persian preamble labelling the repo link as
# "GitHub repo + full Persian guide"
# 4. Repo URL
# 5. Persian preamble labelling the release link as
# "this version's release — desktop/router builds live here"
# 6. Release URL
# Keeps total well under Telegram's 1024-char caption limit.
caption_parts = [
f"<b>mhrv-rs Android v{ver}</b>",
"",
@@ -618,7 +376,7 @@ def main() -> int:
print(f"No changelog at {args.changelog}, skipping reply.")
return 0
parts: list = []
parts = []
if fa:
parts.append(f"<blockquote>{fa}</blockquote>")
if en:
+27 -151
View File
@@ -651,28 +651,16 @@ jobs:
generate_release_notes: true
# Notify the Persian-speaking Telegram channel with the CI-built
# binaries for macOS / Linux / Windows / Android, sent as a single
# media group with one caption listing each filename + SHA-256, plus
# a reply-threaded changelog from `docs/changelog/v<tag>.md`.
# Android APK + its sha256 + the per-version changelog from
# `docs/changelog/v<tag>.md`.
#
# Two Telegram API calls:
# 1. sendMediaGroup — N (2..=10) documents in a single grouped post.
# Telegram only renders the caption attached to the FIRST item,
# so we put the whole filename+SHA list there and leave the rest
# captionless. Caption budget is 1024 chars; a 6-file release
# sits ~860 chars (script falls back to a filename-only list
# with a warning if it ever overflows).
# 2. sendMessage — full changelog as a reply to the first message
# in the media group, Persian blockquote first then English.
# Auto-sent for media-group posts since the per-file SHA list
# pushes the FA brief-note out of the caption.
#
# We send the raw binaries (Mach-O / ELF / PE / APK) extracted from
# the per-platform archives — no .zip / .tar.gz wrappers in the
# channel, per the maintainer's request. macOS users wanting the
# gatekeeper-friendly .app bundle still grab it from the GitHub
# Releases page; the Telegram drop is for the "give me the binary
# and let me run it" path.
# 1. sendDocument — APK file + a short caption (Telegram caps
# captions at 1024 chars, and we have bigger changelogs than
# that).
# 2. sendMessage — full changelog as a reply to #1, Persian
# quote-block first then English, same pattern as the
# previous manual post. No emojis, as the user asked.
#
# Needs two repo secrets:
# TELEGRAM_BOT_TOKEN — bot the channel admits as poster
@@ -695,163 +683,51 @@ jobs:
steps:
- uses: actions/checkout@v4
# Download every release artifact (build matrix + android job),
# not just the universal APK. `merge-multiple: true` flattens the
# per-job artifact dirs into one tree so we don't have to know
# the artifact names in advance.
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
# Pull the raw binary out of each per-platform archive and rename
# it with the version suffix so users see what they're getting at
# a glance in Telegram. Order in `tg-files/` doesn't matter to the
# Telegram client (media-group items are displayed as a stack);
# we sort with the find pipe at the end so the script always
# passes them in the same order across runs (debugging-friendly).
#
# Per-platform binary picks:
# - macOS amd64 / arm64: `mhrv-rs` (CLI). Mach-O binary; users
# run from Terminal. The GUI .app bundle requires the .app
# directory tree (can't ship as a single file), so .app users
# grab the .app.zip from GitHub Releases.
# - Linux amd64 / arm64: `mhrv-rs` (CLI), GLIBC build. Most
# desktop Linux users run from terminal; arm64 doesn't have
# a UI binary in our matrix anyway.
# - Windows amd64: `mhrv-rs.exe` CLI, plus `mhrv-rs-ui.exe` if
# it built (it currently does). The UI binary is what most
# Windows users want; CLI is the backup.
# - Android: the universal APK (already a single file).
#
# `if [ -f ... ]` guards on every extraction — a single-platform
# build failure (rare but possible: the matrix has
# `continue-on-error: true` for mipsel-softfloat) shouldn't kill
# the Telegram post.
- name: Prepare files for Telegram media group
run: |
set -euo pipefail
VER="${{ inputs.version || github.ref_name }}"
VER="${VER#v}"
mkdir -p tg-files extract-tmp
# Helper: extract a named binary out of a tar.gz, rename
# with the version-tagged target name, copy into tg-files/.
# Empties extract-tmp/ between calls so cross-platform binary
# name collisions (everyone packages a `mhrv-rs` inside their
# archive) don't pick up the previous run's file.
extract_tar() {
local archive="$1" platform="$2" inner="${3:-mhrv-rs}" suffix="${4:-}"
if [ ! -f "$archive" ]; then
echo "::warning::missing $archive — skipping $platform"
return 0
fi
rm -rf extract-tmp/* extract-tmp/.[!.]* 2>/dev/null || true
tar xzf "$archive" -C extract-tmp
if [ ! -f "extract-tmp/$inner" ]; then
echo "::warning::$inner not in $archive — skipping $platform"
return 0
fi
cp "extract-tmp/$inner" "tg-files/mhrv-rs-${platform}-v${VER}${suffix}"
echo "prepared mhrv-rs-${platform}-v${VER}${suffix}"
}
# Same shape but for .zip archives (Windows). `unzip -o`
# overwrites without prompting, matching tar's behaviour.
extract_zip() {
local archive="$1" platform="$2" inner="$3" suffix="$4"
if [ ! -f "$archive" ]; then
echo "::warning::missing $archive — skipping $platform"
return 0
fi
rm -rf extract-tmp/* extract-tmp/.[!.]* 2>/dev/null || true
unzip -o "$archive" -d extract-tmp >/dev/null
if [ ! -f "extract-tmp/$inner" ]; then
echo "::warning::$inner not in $archive — skipping $platform"
return 0
fi
cp "extract-tmp/$inner" "tg-files/mhrv-rs-${platform}-v${VER}${suffix}"
echo "prepared mhrv-rs-${platform}-v${VER}${suffix}"
}
# Desktop CLI binaries — primary platforms only. Linux armhf,
# Linux musl, mipsel-softfloat are niche enough to leave on
# the GitHub Releases page rather than crowd the Telegram
# group (Telegram caps a media group at 10 items).
extract_tar "artifacts/mhrv-rs-linux-amd64.tar.gz" "linux-amd64"
extract_tar "artifacts/mhrv-rs-linux-arm64.tar.gz" "linux-arm64"
extract_tar "artifacts/mhrv-rs-macos-amd64.tar.gz" "macos-amd64"
extract_tar "artifacts/mhrv-rs-macos-arm64.tar.gz" "macos-arm64"
# Windows: ship the GUI binary since it's the typical case
# for Windows users; the CLI still goes up via the GitHub
# Releases page.
extract_zip "artifacts/mhrv-rs-windows-amd64.zip" "windows-amd64-ui" "mhrv-rs-ui.exe" ".exe"
# Android universal APK is already a single file with the
# version baked into the name — just copy it across.
if [ -f "artifacts/mhrv-rs-android-universal-v${VER}.apk" ]; then
cp "artifacts/mhrv-rs-android-universal-v${VER}.apk" tg-files/
echo "prepared mhrv-rs-android-universal-v${VER}.apk"
else
echo "::warning::missing universal APK — skipping Android"
fi
echo "--- tg-files/ ---"
ls -la tg-files/
count=$(find tg-files -maxdepth 1 -type f | wc -l)
if [ "$count" -lt 1 ]; then
echo "::error::no files prepared for Telegram post"
exit 1
fi
if [ "$count" -gt 10 ]; then
echo "::error::Telegram media group caps at 10 items, got $count"
exit 1
fi
name: mhrv-rs-android-universal
path: apk
- name: Post to Telegram
env:
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
INCLUDE_CHANGELOG: ${{ vars.TELEGRAM_INCLUDE_CHANGELOG }}
# Python over curl/bash so we don't have to fight curl's -F
# value-interpretation rules. curl treats `-F "caption=<..."`
# as "read the caption from file named ..." when the value
# starts with `<`, which matches our `<b>` HTML-bold tags and
# silently turns the whole job into a "file not found" exit
# 26. Python stdlib has no such wart.
#
# We pass --files once per file in tg-files/. The script
# decides between sendMediaGroup (>=2 files) and sendDocument
# (==1 file) automatically; the changelog reply is always sent
# in the --files path because the per-file SHA list eats the
# caption budget that previously held the FA brief-note.
run: |
set -euo pipefail
VER="${{ inputs.version || github.ref_name }}"
VER="${VER#v}"
APK="apk/mhrv-rs-android-universal-v${VER}.apk"
if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then
echo "::notice::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set, skipping Telegram post"
exit 0
fi
# Build the --files args. `find … -print0 | sort -z` sorts
# the filenames so the order is deterministic across runs
# (helps reading the channel post — same column order each
# release). xargs -0 is used so spaces/newlines in filenames
# don't break the arg list.
FILES_ARGS=()
while IFS= read -r -d '' f; do
FILES_ARGS+=(--files "$f")
done < <(find tg-files -maxdepth 1 -type f -print0 | sort -z)
if [ "${#FILES_ARGS[@]}" -eq 0 ]; then
echo "::error::no --files args to pass; tg-files/ is empty"
if [ ! -f "$APK" ]; then
echo "::error::expected $APK to exist; got:"
ls -la apk/
exit 1
fi
# --with-changelog is opt-in. Default post is just the APK
# plus a short caption with the SHA-256, repo URL, and release
# URL — no long body. To include the Persian/English bullets
# for a specific tag, set the repo variable
# TELEGRAM_INCLUDE_CHANGELOG=true before pushing that tag.
INCLUDE_CHANGELOG_FLAG=""
if [ "${INCLUDE_CHANGELOG:-}" = "true" ]; then
INCLUDE_CHANGELOG_FLAG="--with-changelog"
fi
python3 .github/scripts/telegram_release_notify.py \
"${FILES_ARGS[@]}" \
--apk "$APK" \
--version "$VER" \
--repo "$GITHUB_REPOSITORY" \
--changelog "docs/changelog/v${VER}.md"
--changelog "docs/changelog/v${VER}.md" \
$INCLUDE_CHANGELOG_FLAG