mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 23:54:48 +03:00
ci: post macOS/Linux/Windows/Android binaries as Telegram media group
The Telegram release notifier used to post just the universal APK with a single-document caption. This change ships the per-platform binaries for macOS (amd64+arm64 CLI), Linux (amd64+arm64 CLI), Windows (amd64 UI), and Android (universal APK) as a single Telegram media group with one caption listing every filename + SHA-256. Workflow side (.github/workflows/release.yml): - The telegram job now downloads ALL artifacts (was: APK only). - New `Prepare files for Telegram media group` step extracts the raw binaries out of each per-platform .tar.gz / .zip (no archive wrappers in the channel) and renames them with version suffixes (mhrv-rs-linux-amd64-v1.7.2, mhrv-rs-windows-amd64-ui-v1.7.2.exe, etc.). Per-platform extraction is best-effort: a missing artifact emits a `::warning::` and skips that platform rather than failing the whole post. - The post step builds a `--files <path>` arg list from tg-files/, sorted for deterministic order across runs, and invokes the notifier without --with-changelog (the script auto-replies with changelog whenever --files is used). Script side (.github/scripts/telegram_release_notify.py): - New --files arg (repeatable). 2..=10 files → sendMediaGroup; 1 file → sendDocument with the same caption shape; 0 → error. Telegram's sendMediaGroup rejects single-item groups, so the 1-file fallback isn't optional. - New build_media_group_caption() composes title + per-file filename+SHA list + repo/release URLs. Fits ~860 chars for a 6-file release; fallback to filename-only-list if a future swell pushes past Telegram's 1024-char caption cap. - send_media_group() handles the multipart/form-data shape with each file referenced as `attach://fileN` from the media JSON. Caption is attached to file 0 only (Telegram clients render per-item captions inconsistently for media groups; first-item-only is the safe pattern). - Legacy --apk path kept for any caller that hasn't migrated; either --apk or --files must be present (validated at startup). - _content_type_for() picks application/vnd.android.package-archive for .apk and application/octet-stream for everything else, so Telegram clients label the APK with the Android icon and label desktop binaries by filename without a misleading icon. Behavioural change for users: - The Telegram channel now sees one grouped post per release with all primary platform binaries inline, instead of just the APK. macOS users wanting the gatekeeper-friendly .app.zip still grab it from the GitHub Releases page; the Telegram drop is for the "give me the binary, I'll run it" path. - The Persian/English changelog reply that used to be opt-in (via TELEGRAM_INCLUDE_CHANGELOG=true) is now automatic in the --files path because the per-file SHA list eats the caption budget that previously held the FA brief-note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -202,6 +202,81 @@ def sha256_of(path: str) -> str:
|
|||||||
return h.hexdigest()
|
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:
|
def tg_request(method: str, token: str, *, body: bytes, content_type: str) -> dict:
|
||||||
"""POST `body` to https://api.telegram.org/bot<token>/<method>."""
|
"""POST `body` to https://api.telegram.org/bot<token>/<method>."""
|
||||||
conn = http.client.HTTPSConnection(
|
conn = http.client.HTTPSConnection(
|
||||||
@@ -224,10 +299,30 @@ def tg_request(method: str, token: str, *, body: bytes, content_type: str) -> di
|
|||||||
return data["result"]
|
return data["result"]
|
||||||
|
|
||||||
|
|
||||||
def send_document(token: str, chat_id: str, apk_path: str, caption: str) -> int:
|
def _content_type_for(path: str) -> str:
|
||||||
"""Upload the APK file with a short HTML caption. Returns message_id."""
|
"""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.
|
||||||
|
"""
|
||||||
boundary = "----" + uuid.uuid4().hex
|
boundary = "----" + uuid.uuid4().hex
|
||||||
with open(apk_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
file_bytes = f.read()
|
file_bytes = f.read()
|
||||||
|
|
||||||
def text_field(name: str, value: str) -> bytes:
|
def text_field(name: str, value: str) -> bytes:
|
||||||
@@ -237,13 +332,11 @@ def send_document(token: str, chat_id: str, apk_path: str, caption: str) -> int:
|
|||||||
f"{value}\r\n"
|
f"{value}\r\n"
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
|
|
||||||
def file_field(name: str, filename: str, content: bytes) -> bytes:
|
def file_field(name: str, filename: str, content: bytes, content_type: str) -> bytes:
|
||||||
head = (
|
head = (
|
||||||
f"--{boundary}\r\n"
|
f"--{boundary}\r\n"
|
||||||
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
|
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
|
||||||
# Proper MIME type — makes the Telegram client show the APK
|
f"Content-Type: {content_type}\r\n\r\n"
|
||||||
# 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")
|
).encode("utf-8")
|
||||||
return head + content + b"\r\n"
|
return head + content + b"\r\n"
|
||||||
|
|
||||||
@@ -251,7 +344,12 @@ def send_document(token: str, chat_id: str, apk_path: str, caption: str) -> int:
|
|||||||
text_field("chat_id", chat_id)
|
text_field("chat_id", chat_id)
|
||||||
+ text_field("caption", caption)
|
+ text_field("caption", caption)
|
||||||
+ text_field("parse_mode", "HTML")
|
+ text_field("parse_mode", "HTML")
|
||||||
+ file_field("document", os.path.basename(apk_path), file_bytes)
|
+ file_field(
|
||||||
|
"document",
|
||||||
|
os.path.basename(file_path),
|
||||||
|
file_bytes,
|
||||||
|
_content_type_for(file_path),
|
||||||
|
)
|
||||||
+ f"--{boundary}--\r\n".encode("utf-8")
|
+ f"--{boundary}--\r\n".encode("utf-8")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -264,6 +362,86 @@ def send_document(token: str, chat_id: str, apk_path: str, caption: str) -> int:
|
|||||||
return int(result["message_id"])
|
return int(result["message_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def send_media_group(
|
||||||
|
token: str, chat_id: str, file_paths: list, caption: str
|
||||||
|
) -> int:
|
||||||
|
"""Upload 2–10 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 × ~5–15 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:
|
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."""
|
"""Post a text message as a reply to the APK message."""
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
@@ -286,27 +464,51 @@ def send_reply(token: str, chat_id: str, text: str, reply_to: int) -> None:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--apk", required=True)
|
# 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("--version", required=True)
|
ap.add_argument("--version", required=True)
|
||||||
ap.add_argument("--repo", required=True)
|
ap.add_argument("--repo", required=True)
|
||||||
ap.add_argument("--changelog", required=True,
|
ap.add_argument("--changelog", required=True,
|
||||||
help="Path to docs/changelog/vX.Y.Z.md; only read when --with-changelog is passed.")
|
help="Path to docs/changelog/vX.Y.Z.md; read for the "
|
||||||
# Default: just the APK + short caption (title + SHA-256 + repo URL +
|
"FA brief-note in the legacy caption (--apk path) "
|
||||||
# release URL). The per-release Persian/English blockquote reply is
|
"and for the reply-threaded changelog message.")
|
||||||
# opt-in via `--with-changelog` so routine releases don't flood the
|
# Default for --apk path: just the APK + short caption.
|
||||||
# channel with bullet-point bodies. To re-enable for a specific tag:
|
# For --files path: the FA+EN reply is automatic since the caption is
|
||||||
# set the repo variable TELEGRAM_INCLUDE_CHANGELOG=true before pushing
|
# full of SHA hashes; toggle is ignored in that case.
|
||||||
# the tag (the workflow converts that into --with-changelog).
|
|
||||||
ap.add_argument("--with-changelog", action="store_true",
|
ap.add_argument("--with-changelog", action="store_true",
|
||||||
help="Include the Persian+English changelog as a reply-threaded message.")
|
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.")
|
||||||
# Dry-run lets you verify the rendered caption locally without hitting
|
# Dry-run lets you verify the rendered caption locally without hitting
|
||||||
# Telegram. Useful when changing the brief-release-note budget /
|
# Telegram. Useful when changing caption layout — print, eyeball, push.
|
||||||
# truncation logic — print, eyeball, push.
|
|
||||||
ap.add_argument("--dry-run", action="store_true",
|
ap.add_argument("--dry-run", action="store_true",
|
||||||
help="Render the caption and print it instead of posting. "
|
help="Render the caption and print it instead of posting. "
|
||||||
"Skips token/chat_id checks.")
|
"Skips token/chat_id checks.")
|
||||||
args = ap.parse_args()
|
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:
|
if not args.dry_run:
|
||||||
token = os.environ.get("BOT_TOKEN", "")
|
token = os.environ.get("BOT_TOKEN", "")
|
||||||
chat_id = os.environ.get("CHAT_ID", "")
|
chat_id = os.environ.get("CHAT_ID", "")
|
||||||
@@ -318,24 +520,64 @@ def main() -> int:
|
|||||||
chat_id = ""
|
chat_id = ""
|
||||||
|
|
||||||
ver = args.version
|
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)
|
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)
|
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 = [
|
caption_parts = [
|
||||||
f"<b>mhrv-rs Android v{ver}</b>",
|
f"<b>mhrv-rs Android v{ver}</b>",
|
||||||
"",
|
"",
|
||||||
@@ -376,7 +618,7 @@ def main() -> int:
|
|||||||
print(f"No changelog at {args.changelog}, skipping reply.")
|
print(f"No changelog at {args.changelog}, skipping reply.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
parts = []
|
parts: list = []
|
||||||
if fa:
|
if fa:
|
||||||
parts.append(f"<blockquote>{fa}</blockquote>")
|
parts.append(f"<blockquote>{fa}</blockquote>")
|
||||||
if en:
|
if en:
|
||||||
|
|||||||
+151
-27
@@ -651,16 +651,28 @@ jobs:
|
|||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
|
|
||||||
# Notify the Persian-speaking Telegram channel with the CI-built
|
# Notify the Persian-speaking Telegram channel with the CI-built
|
||||||
# Android APK + its sha256 + the per-version changelog from
|
# binaries for macOS / Linux / Windows / Android, sent as a single
|
||||||
# `docs/changelog/v<tag>.md`.
|
# media group with one caption listing each filename + SHA-256, plus
|
||||||
|
# a reply-threaded changelog from `docs/changelog/v<tag>.md`.
|
||||||
#
|
#
|
||||||
# Two Telegram API calls:
|
# Two Telegram API calls:
|
||||||
# 1. sendDocument — APK file + a short caption (Telegram caps
|
# 1. sendMediaGroup — N (2..=10) documents in a single grouped post.
|
||||||
# captions at 1024 chars, and we have bigger changelogs than
|
# Telegram only renders the caption attached to the FIRST item,
|
||||||
# that).
|
# so we put the whole filename+SHA list there and leave the rest
|
||||||
# 2. sendMessage — full changelog as a reply to #1, Persian
|
# captionless. Caption budget is 1024 chars; a 6-file release
|
||||||
# quote-block first then English, same pattern as the
|
# sits ~860 chars (script falls back to a filename-only list
|
||||||
# previous manual post. No emojis, as the user asked.
|
# 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.
|
||||||
#
|
#
|
||||||
# Needs two repo secrets:
|
# Needs two repo secrets:
|
||||||
# TELEGRAM_BOT_TOKEN — bot the channel admits as poster
|
# TELEGRAM_BOT_TOKEN — bot the channel admits as poster
|
||||||
@@ -683,51 +695,163 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: mhrv-rs-android-universal
|
path: artifacts
|
||||||
path: apk
|
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: Post to Telegram
|
- name: Post to Telegram
|
||||||
env:
|
env:
|
||||||
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
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
|
# Python over curl/bash so we don't have to fight curl's -F
|
||||||
# value-interpretation rules. curl treats `-F "caption=<..."`
|
# value-interpretation rules. curl treats `-F "caption=<..."`
|
||||||
# as "read the caption from file named ..." when the value
|
# as "read the caption from file named ..." when the value
|
||||||
# starts with `<`, which matches our `<b>` HTML-bold tags and
|
# starts with `<`, which matches our `<b>` HTML-bold tags and
|
||||||
# silently turns the whole job into a "file not found" exit
|
# silently turns the whole job into a "file not found" exit
|
||||||
# 26. Python stdlib has no such wart.
|
# 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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VER="${{ inputs.version || github.ref_name }}"
|
VER="${{ inputs.version || github.ref_name }}"
|
||||||
VER="${VER#v}"
|
VER="${VER#v}"
|
||||||
APK="apk/mhrv-rs-android-universal-v${VER}.apk"
|
|
||||||
|
|
||||||
if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then
|
if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then
|
||||||
echo "::notice::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set, skipping Telegram post"
|
echo "::notice::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set, skipping Telegram post"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f "$APK" ]; then
|
# Build the --files args. `find … -print0 | sort -z` sorts
|
||||||
echo "::error::expected $APK to exist; got:"
|
# the filenames so the order is deterministic across runs
|
||||||
ls -la apk/
|
# (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"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 \
|
python3 .github/scripts/telegram_release_notify.py \
|
||||||
--apk "$APK" \
|
"${FILES_ARGS[@]}" \
|
||||||
--version "$VER" \
|
--version "$VER" \
|
||||||
--repo "$GITHUB_REPOSITORY" \
|
--repo "$GITHUB_REPOSITORY" \
|
||||||
--changelog "docs/changelog/v${VER}.md" \
|
--changelog "docs/changelog/v${VER}.md"
|
||||||
$INCLUDE_CHANGELOG_FLAG
|
|
||||||
|
|||||||
Reference in New Issue
Block a user