mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 05:44:35 +03:00
v1.5.0: long-poll Full Tunnel + Docker tunnel-node + brief FA release notes
Ships PR #173 (event-driven drain) plus three operational improvements: PR #173 — long-poll tunnel mode. The tunnel-node's batch drain switched from a fixed 150 ms sleep to an event-driven Notify wait; idle sessions long-poll up to 5 s and wake on the first byte from upstream. Push notifications and chat messages now arrive in roughly RTT instead of waiting for the next client poll tick. Backward compat with pre-#173 tunnel-nodes is automatic via a sticky AtomicBool that detects fast empty replies and reverts to the legacy cadence. 92 client tests + 17 tunnel-node tests pass, including end-to-end TCP-pair verification of the notify wiring. Docker image for tunnel-node. Adds a hardened Dockerfile (BuildKit cache mounts, non-root runtime user, ca-certificates for HTTPS upstreams) and a .dockerignore to keep build context small. New `tunnel-docker` job in the release workflow builds + pushes multi-arch (linux/amd64 + linux/arm64) to ghcr.io/therealaleph/mhrv-tunnel-node with `:latest`, `:1.5`, and `:1.5.0` tags on every release. Setting up Full Tunnel mode goes from "rustup + cargo build on a 1 GB VPS" (which fails on memory half the time) to a one-liner. tunnel-node/README.md updated with prebuilt-image + docker-compose recipes. Brief Persian release note in Telegram caption. The release-post caption now leads with a `<blockquote>`-wrapped FA bullet headlines extracted from `docs/changelog/v<ver>.md`, above the existing two links (repo + release). Markdown links → Telegram HTML <a> for clickability. Cap-budget-aware truncation at bullet boundaries keeps total caption under Telegram's 1024-char limit. Headlines-only rather than full bullets so multiple "what's new" items fit comfortably (the full bullets remain on the GH release page and as the optional --with-changelog reply-threaded message). GitHub Releases page bodies now lead with the changelog content (Persian section + `---` + English) instead of just a Full Changelog comparison link. The auto comparison link is appended at the bottom via `append_body: true` rather than removed. Workflow changes: - New `permissions: packages: write` at the workflow level (required for ghcr push via docker/login-action). - New `tunnel-docker` job needs `build` (not the full matrix) to serialize the QEMU buildx layer with the matrix cache. - Release job composes the body from `docs/changelog/v${VER}.md` in a pre-step that handles both tag-push and workflow_dispatch paths (uses inputs.version || github.ref_name like the rest of the workflow). Tested locally: - `cargo test` — 92 lib tests pass - `cargo test -p mhrv-tunnel-node` — 17 tests pass - `docker build` of tunnel-node Dockerfile — 32 MB image, runs as non-root, /health returns "ok", auth rejection works correctly, legitimate requests open sessions to remote hosts - Telegram script `--dry-run` mode added; rendered captions for v1.4.0, v1.4.1, v1.5.0 all fit under 900 chars Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,110 @@ def parse_changelog(path: str) -> tuple[str, str]:
|
||||
return fa.strip(), en.strip()
|
||||
|
||||
|
||||
# Telegram caption hard-cap is 1024 chars. The fixed parts of our caption
|
||||
# (title + SHA hash + two-link footer with their preambles) sum to roughly
|
||||
# 470 chars on a typical version string. That leaves ~550 chars for the
|
||||
# release-note section before we'd start losing the trailing release URL.
|
||||
# Keep the budget conservative so a long version string or a slightly
|
||||
# longer hash representation doesn't push us over.
|
||||
CAPTION_FA_NOTE_BUDGET = 500
|
||||
|
||||
|
||||
def _md_links_to_html(text: str) -> str:
|
||||
"""Convert `[label](url)` markdown links to `<a href="url">label</a>`.
|
||||
|
||||
Telegram's HTML parse mode renders `<a>` as clickable but treats
|
||||
markdown verbatim, so an unconverted `[#160](https://…)` appears as
|
||||
that literal string in the channel post — both ugly and wasteful of
|
||||
caption budget. The HTML form is shorter visually (`#160` vs the
|
||||
full URL), still clickable, and counts the same toward Telegram's
|
||||
1024-char limit. Inline `code` (`backtick-quoted`) is also
|
||||
translated to `<code>…</code>` since markdown backticks render
|
||||
literally too.
|
||||
"""
|
||||
text = re.sub(
|
||||
r"\[([^\]]+)\]\(([^)]+)\)",
|
||||
lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>',
|
||||
text,
|
||||
)
|
||||
text = re.sub(r"`([^`\n]+)`", r"<code>\1</code>", text)
|
||||
# Bold (**…**) is rare in our changelog but happens — convert to <b>.
|
||||
text = re.sub(r"\*\*([^*\n]+)\*\*", r"<b>\1</b>", text)
|
||||
return text
|
||||
|
||||
|
||||
def _extract_headlines(fa_section: str) -> str:
|
||||
"""For each `• …: …` bullet, keep the headline part and drop the
|
||||
elaboration.
|
||||
|
||||
Our changelog convention writes each bullet as one of:
|
||||
• headline: full explanation
|
||||
• headline ([#NN](url)): full explanation
|
||||
• headline (issue ref): full explanation
|
||||
|
||||
The headline is everything up to the `: ` (colon + space) that ends
|
||||
the leading clause. Naively searching for the first `:` lands inside
|
||||
`https:` URLs of the markdown link form — instead we search from the
|
||||
end of the parenthesized-issue-ref (if any) for the first `: `, or
|
||||
fall back to the first `: ` in the line.
|
||||
|
||||
Headlines stay on the FA caption; the explanation is preserved in
|
||||
the docs/changelog/ file and (optionally) the reply-threaded message
|
||||
posted via --with-changelog.
|
||||
|
||||
Returns a newline-joined string of `• <headline>` lines.
|
||||
"""
|
||||
headlines: list[str] = []
|
||||
for line in fa_section.splitlines():
|
||||
if not line.startswith("• "):
|
||||
continue
|
||||
body = line[2:] # drop "• "
|
||||
# Prefer cutting at "): " — the close of the parenthesized ref
|
||||
# followed by the convention colon + space. That's our actual
|
||||
# bullet structure and avoids the false-positive `https:` cut.
|
||||
cut_idx = body.find("): ")
|
||||
if cut_idx > 0:
|
||||
headline = body[: cut_idx + 1] # keep the close paren
|
||||
else:
|
||||
# Fall back to ": " (colon + space) anywhere in the body.
|
||||
# Adding the space requirement skips `https:` which is
|
||||
# always followed by `/`.
|
||||
cut_idx = body.find(": ")
|
||||
headline = body[:cut_idx] if cut_idx > 0 else body
|
||||
headlines.append(f"• {headline.rstrip()}")
|
||||
return "\n".join(headlines)
|
||||
|
||||
|
||||
def build_caption_release_note(changelog_path: str) -> str:
|
||||
"""Build the Persian "what's new" block for the Telegram caption.
|
||||
|
||||
Pulls the FA section of `docs/changelog/v<ver>.md`, extracts just
|
||||
the bullet headlines (before the first `:` of each bullet) so the
|
||||
note is compact, converts markdown links/code to Telegram HTML for
|
||||
clickability, and wraps in a `<blockquote>`. Falls back to the full
|
||||
FA section if the headlines extraction yields nothing (e.g. a
|
||||
changelog that doesn't follow our `• headline: details` convention).
|
||||
|
||||
If the result still exceeds CAPTION_FA_NOTE_BUDGET, truncate at a
|
||||
bullet boundary with a trailing `…`. In practice the headlines-only
|
||||
form fits comfortably for any reasonable release note.
|
||||
"""
|
||||
fa, _en = parse_changelog(changelog_path)
|
||||
if not fa:
|
||||
return ""
|
||||
headlines = _extract_headlines(fa)
|
||||
note = headlines if headlines else fa.strip()
|
||||
note = _md_links_to_html(note)
|
||||
if len(note) > CAPTION_FA_NOTE_BUDGET:
|
||||
truncated = note[:CAPTION_FA_NOTE_BUDGET]
|
||||
last_bullet = truncated.rfind("\n•")
|
||||
if last_bullet > 0:
|
||||
note = truncated[:last_bullet].rstrip() + "\n…"
|
||||
else:
|
||||
note = truncated.rstrip() + "…"
|
||||
return f"<blockquote>{note}</blockquote>"
|
||||
|
||||
|
||||
def sha256_of(path: str) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
@@ -169,33 +273,70 @@ def main() -> int:
|
||||
# the tag (the workflow converts that into --with-changelog).
|
||||
ap.add_argument("--with-changelog", action="store_true",
|
||||
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 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()
|
||||
|
||||
token = os.environ.get("BOT_TOKEN", "")
|
||||
chat_id = os.environ.get("CHAT_ID", "")
|
||||
if not token or not chat_id:
|
||||
print("TELEGRAM secrets not present, skipping post.")
|
||||
return 0
|
||||
if not args.dry_run:
|
||||
token = os.environ.get("BOT_TOKEN", "")
|
||||
chat_id = os.environ.get("CHAT_ID", "")
|
||||
if not token or not chat_id:
|
||||
print("TELEGRAM secrets not present, skipping post.")
|
||||
return 0
|
||||
else:
|
||||
token = ""
|
||||
chat_id = ""
|
||||
|
||||
ver = args.version
|
||||
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. Persian preamble labelling the repo link as
|
||||
# 2. Brief Persian "what's new" note (extracted from changelog)
|
||||
# 3. Persian preamble labelling the repo link as
|
||||
# "GitHub repo + full Persian guide"
|
||||
# 3. Repo URL
|
||||
# 4. Persian preamble labelling the release link as
|
||||
# 4. Repo URL
|
||||
# 5. Persian preamble labelling the release link as
|
||||
# "this version's release — desktop/router builds live here"
|
||||
# 5. Release URL
|
||||
# 6. Release URL
|
||||
# Keeps total well under Telegram's 1024-char caption limit.
|
||||
caption = (
|
||||
f"<b>mhrv-rs Android v{ver}</b>\n\n"
|
||||
f"SHA-256: <code>{sha}</code>\n\n"
|
||||
f"مخزن گیتهاب + مطالعه راهنمای کامل فارسی:\n"
|
||||
f"https://github.com/{args.repo}\n\n"
|
||||
f"لینک به این نسخه جهت دریافت نسخه های مربوط به مودم و کامپیوتر:\n"
|
||||
f"https://github.com/{args.repo}/releases/tag/v{ver}"
|
||||
)
|
||||
caption_parts = [
|
||||
f"<b>mhrv-rs Android v{ver}</b>",
|
||||
"",
|
||||
f"SHA-256: <code>{sha}</code>",
|
||||
]
|
||||
if fa_note:
|
||||
caption_parts.extend(["", fa_note])
|
||||
caption_parts.extend([
|
||||
"",
|
||||
"مخزن گیتهاب + مطالعه راهنمای کامل فارسی:",
|
||||
f"https://github.com/{args.repo}",
|
||||
"",
|
||||
"لینک به این نسخه جهت دریافت نسخه های مربوط به مودم و کامپیوتر:",
|
||||
f"https://github.com/{args.repo}/releases/tag/v{ver}",
|
||||
])
|
||||
caption = "\n".join(caption_parts)
|
||||
|
||||
if args.dry_run:
|
||||
print(f"--- DRY RUN: caption ({len(caption)} chars) ---")
|
||||
print(caption)
|
||||
print(f"--- END DRY RUN ---")
|
||||
if args.with_changelog:
|
||||
fa, en = parse_changelog(args.changelog)
|
||||
print(f"\nWould reply with changelog "
|
||||
f"(fa: {len(fa) if fa else 0} chars, "
|
||||
f"en: {len(en) if en else 0} chars)")
|
||||
return 0
|
||||
|
||||
doc_mid = send_document(token, chat_id, args.apk, caption)
|
||||
print(f"sendDocument OK, message_id={doc_mid}")
|
||||
|
||||
@@ -22,6 +22,13 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# `tunnel-docker` job pushes to ghcr.io/therealaleph/mhrv-tunnel-node.
|
||||
# `packages: write` is required by docker/login-action when authenticating
|
||||
# to GHCR with the workflow's auto-provisioned GITHUB_TOKEN. Granted at
|
||||
# the workflow level so the matrix-build job (which doesn't need it) and
|
||||
# the release job (which doesn't need it) both still have a single
|
||||
# well-scoped permissions block.
|
||||
packages: write
|
||||
|
||||
# Runner strategy:
|
||||
# - Linux + Android + mipsel: self-hosted (mhrv-hetzner-*, Hetzner
|
||||
@@ -487,6 +494,75 @@ jobs:
|
||||
path: dist/*.apk
|
||||
if-no-files-found: error
|
||||
|
||||
# Build + publish the tunnel-node Docker image to GHCR. Issue: every
|
||||
# full-mode user has to set up tunnel-node on a VPS, and "rustup +
|
||||
# cargo build --release" on a 1GB VPS is non-trivial — fails on memory,
|
||||
# takes 8+ minutes if it works, blocks anyone without Rust experience.
|
||||
# A prebuilt multi-arch image makes deployment a one-liner:
|
||||
# docker run -d -p 8080:8080 -e TUNNEL_AUTH_KEY=... \
|
||||
# ghcr.io/therealaleph/mhrv-tunnel-node:latest
|
||||
#
|
||||
# Tags published per release:
|
||||
# v1.5.0 — exact version pin
|
||||
# 1.5 — auto-following minor
|
||||
# latest — most recent release (skipped on workflow_dispatch
|
||||
# re-publishes; see `latest` condition below)
|
||||
#
|
||||
# Build platforms: linux/amd64 and linux/arm64. Most VPS providers
|
||||
# (DigitalOcean, Hetzner, Oracle Free Tier) offer arm64 instances at
|
||||
# half price, and the binary works on both.
|
||||
tunnel-docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Compute the version string the same way the rest of the workflow
|
||||
# does: tag pushes get it from github.ref_name (e.g. "v1.5.0"),
|
||||
# workflow_dispatch from the explicit `inputs.version` (e.g.
|
||||
# "1.5.0"). Strip a possible leading "v" so the docker tag namespace
|
||||
# is consistent: `:1.5.0`, not `:v1.5.0`.
|
||||
- name: Compute version
|
||||
id: ver
|
||||
run: |
|
||||
VER="${{ inputs.version || github.ref_name }}"
|
||||
VER="${VER#v}"
|
||||
MINOR="${VER%.*}"
|
||||
echo "version=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "minor=${MINOR}" >> "$GITHUB_OUTPUT"
|
||||
echo "Building docker for v${VER} (minor: ${MINOR})"
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build for both amd64 and arm64. `:latest` is only updated on
|
||||
# actual tag pushes — workflow_dispatch re-runs on an existing
|
||||
# version (e.g. for the v1.4.0 mipsel republish) shouldn't move
|
||||
# the latest pointer.
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./tunnel-node
|
||||
file: ./tunnel-node/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/mhrv-tunnel-node:${{ steps.ver.outputs.version }}
|
||||
ghcr.io/${{ github.repository_owner }}/mhrv-tunnel-node:${{ steps.ver.outputs.minor }}
|
||||
${{ github.event_name == 'push' && format('ghcr.io/{0}/mhrv-tunnel-node:latest', github.repository_owner) || '' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# release + telegram: lightweight aggregation jobs kept on GH-hosted
|
||||
# ubuntu-latest. They only download artifacts and call APIs — no build
|
||||
# tooling needed, no benefit from moving to self-hosted, and keeping them
|
||||
@@ -507,6 +583,38 @@ jobs:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
# Compose the GitHub release body from `docs/changelog/v<ver>.md`
|
||||
# so the Releases page tells humans what actually changed —
|
||||
# `generate_release_notes: true` alone produces "Full Changelog:
|
||||
# …compare/v1.x.0...v1.x.1" which is empty when no PRs landed
|
||||
# between tags (e.g. for fix-forward releases like v1.4.1). The
|
||||
# changelog file already exists for every release in our format
|
||||
# (Persian section, then `---`, then English section); we wrap it
|
||||
# with a header and append the auto-generated commit list at the
|
||||
# bottom by NOT setting body_path and instead setting body
|
||||
# directly to changelog_content + (the existing
|
||||
# generate_release_notes flag handles the trailing comparison
|
||||
# link automatically).
|
||||
- name: Compose release body
|
||||
id: relbody
|
||||
run: |
|
||||
VER="${{ inputs.version || github.ref_name }}"
|
||||
VER="${VER#v}"
|
||||
CHANGELOG="docs/changelog/v${VER}.md"
|
||||
if [ ! -f "$CHANGELOG" ]; then
|
||||
echo "::warning::no changelog at $CHANGELOG; release body will fall back to generate_release_notes only"
|
||||
echo "has_changelog=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
{
|
||||
echo 'body<<__RELEASE_BODY_EOF__'
|
||||
# Strip leading HTML comment that documents the file format.
|
||||
sed -e '1{/^<!--/d;}' "$CHANGELOG"
|
||||
echo
|
||||
echo '__RELEASE_BODY_EOF__'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "has_changelog=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
@@ -519,6 +627,13 @@ jobs:
|
||||
# tags are `v1.4.0`, not `1.4.0`).
|
||||
tag_name: ${{ inputs.version && format('v{0}', inputs.version) || github.ref_name }}
|
||||
files: dist/*
|
||||
# Append auto-generated comparison link AFTER our changelog
|
||||
# body — `append_body: true` puts our body first, then the
|
||||
# auto notes. If no changelog file existed, body is empty and
|
||||
# the auto notes carry the whole release-page content (same
|
||||
# behavior as before this change).
|
||||
body: ${{ steps.relbody.outputs.body }}
|
||||
append_body: true
|
||||
generate_release_notes: true
|
||||
|
||||
# Notify the Persian-speaking Telegram channel with the CI-built
|
||||
|
||||
Reference in New Issue
Block a user