Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
workflow_dispatch:
inputs:
version:
description: "New version X.Y.Z; merges after full CI, then publishes via the tag workflow"
description: "Version X.Y.Z; prepares a new release or resumes the current version if its tag is absent"
required: true
type: string

Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ branch names, credentials, signing material, or other secrets.
with one sentence per change.
Keep GitHub notes in `release-notes/X.Y.Z.md` separate from F-Droid's 500-character summaries;
preview existing releases through `release_notes_preview` without rewriting published history.
- Automated release PRs use a merge commit preserving the release branch history; verify both
parents and the tested tree before tagging the actual merge commit.
- Automated release PRs request a merge commit; finalization also accepts squash merges. Verify
the tested base (and release head for two-parent merges) and exact tested tree before tagging
the actual merged commit. Same-version preparation may resume the unique merged release PR
only while its remote tag is absent; preserve versionCode and committed notes.
- Tagged releases publish signed APKs and `SHA256SUMS` to GitHub Releases. Google Play integration
is removed. Document installation from release assets; PR APKs are for testing only.
- Tagged releases scan all signed APKs with VirusTotal before publication using the release-only
Expand Down
2 changes: 1 addition & 1 deletion docs/en/fastlane.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ explicit `JAVA_HOME` fails early. `bundle exec fastlane lanes` lists the checked
| `bundle exec fastlane android debug_artifact` | Builds `app/build/outputs/apk/debug/app-debug.apk`. |
| `bundle exec fastlane android release_prepare version:0.1.2` | Generates EN/RU notes, increments the version and creates a release PR (requires API/token setup). |
| `bundle exec fastlane android release_notes_preview version:1.1.3 ref:v1.1.3` | Previews concise EN/RU GitHub and F-Droid notes for an explicit Git range without publishing. |
| `bundle exec fastlane android release_finish version:0.1.2 pr:123 head:FULL_SHA` | Requires full CI, merges the specified PR head with a merge commit and tags the merged commit. |
| `bundle exec fastlane android release_finish version:0.1.2 pr:123 head:FULL_SHA` | Requires full CI, merges the specified PR head or accepts an existing squash merge, and tags the verified result. |
| `bundle exec fastlane android virus_total` | Scan signed APKs in `dist/release` (or `artifacts:PATH`); requires `VIRUSTOTAL_API_KEY`, blocks on detections/API failures. |
| `bundle exec fastlane android release_artifacts` | Builds and verifies the signed release APKs and `SHA256SUMS` in `dist/release`. |

Expand Down
15 changes: 12 additions & 3 deletions docs/en/release-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,20 @@ allows PR CI and the tag-triggered release build to run normally.
links after merge; this allows a verified retry of tag creation.
7. Recheck PR identity and unchanged `main`, then request a merge commit with the
expected head SHA. GitHub protections still apply. Verify that the resulting
commit belongs to `main`, has the verified base and head as its two parents,
and has exactly the tested head's tree.
commit belongs to `main` and has exactly the tested head's tree. Its parents must
be the verified base and head for a merge commit, or only the verified base for
a manually squash-merged PR. Both forms can be finalized without moving history.
8. Create lightweight tag `vX.Y.Z` on **the actual merged commit**, never on the
release branch head or whatever `main` happens to point to later. The PAT causes
the existing tag build to run. Its new GitHub Release uses the same committed
GitHub notes from `release-notes/X.Y.Z.md`. A finalization rerun accepts an existing
matching tag and never moves a conflicting tag.

The version must exceed both the version in code and the previous stable tag.
New releases must exceed both the version in code and the previous stable tag.
If the requested version equals the current version and its remote tag is absent,
preparation resumes the unique merged release PR instead of changing versionCode,
regenerating notes, or creating another PR. Finalization rechecks its CI, base and tree.
An existing tag still blocks preparation; older versions remain rejected.
Prereleases and automatic version selection are deliberately unsupported. Preparation
and finalization execute trusted workflow source, not code fetched from the release PR.
The release commit may change only Gradle's two version fields, the ten changelog files
Expand All @@ -122,6 +127,10 @@ check leaves a reviewable PR without a tag. After fixing/re-running its CI, use
unchanged. This reuses preparation outputs without paying for another API response.
If the PR was already merged but tag creation failed, the same finalization rerun
rechecks CI/tree and creates or verifies the tag without merging again.
This also works after squash merge with the same version. If the helper itself
was fixed, run `release_finish` from updated trusted `main`: rerunning an old
workflow uses its original code. A new workflow dispatch with the same version
resumes the merged release PR when its tag is absent.

If `main` or the PR head changed, the old finalization cannot approve that new code.
Update the branch deliberately so its single release commit is based on current
Expand Down
2 changes: 1 addition & 1 deletion docs/ru/fastlane.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Ruby 3.4.10 (`.ruby-version`), затем выполните `bundle install`.
| `bundle exec fastlane android debug_artifact` | Собирает `app/build/outputs/apk/debug/app-debug.apk`. |
| `bundle exec fastlane android release_prepare version:0.1.2` | Генерирует EN/RU changelog, повышает версию и создаёт release PR (нужна настройка API/токенов). |
| `bundle exec fastlane android release_notes_preview version:1.1.3 ref:v1.1.3` | Выводит тезисные EN/RU тексты GitHub и F-Droid для указанного диапазона Git без публикации. |
| `bundle exec fastlane android release_finish version:0.1.2 pr:123 head:FULL_SHA` | Ждёт полного CI, выполняет слияние указанного head PR с отдельным merge-коммитом и тегирует итоговый commit. |
| `bundle exec fastlane android release_finish version:0.1.2 pr:123 head:FULL_SHA` | Ждёт полного CI, сливает указанный head PR либо принимает существующий squash merge и тегирует проверенный результат. |
| `bundle exec fastlane android virus_total` | Проверяет подписанные APK в `dist/release` (или `artifacts:PATH`); нужен `VIRUSTOTAL_API_KEY`, обнаружения/ошибки API блокируют публикацию. |
| `bundle exec fastlane android release_artifacts` | Собирает и проверяет подписанные APK и `SHA256SUMS` в `dist/release`. |

Expand Down
14 changes: 12 additions & 2 deletions docs/ru/release-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,21 @@ workflow с прямым merge не поддерживается. Правила
повторное создание тега.
7. Повторная проверка PR и неизменности `main`, merge с отдельным merge-коммитом и ожидаемым head SHA.
Правила GitHub продолжают действовать. Проверка принадлежности итогового
commit к `main`, двух родителей (проверенные base и head) и точного
совпадения его дерева с проверенным head.
commit к `main` и точного совпадения его дерева с проверенным head.
Родители должны совпадать с проверенными base и head для merge-коммита
либо только с base для PR, вручную слитого через squash. Оба варианта
поддерживают finalize без изменения истории.
8. Lightweight-тег `vX.Y.Z` ставится **на фактический commit после merge**,
не на head ветки и не на более поздний случайный `main`. PAT запускает обычную
сборку по тегу. Новый GitHub Release использует EN/RU текст из `release-notes/X.Y.Z.md`.
Повтор finalize принимает уже существующий правильный тег, но никогда
не переставляет тег, указывающий на другой commit.

Новая версия должна быть выше версии в коде и последнего стабильного тега.
Если запрошена текущая версия и её удалённого тега ещё нет, подготовка продолжает
единственный слитый релизный PR: без изменения versionCode, повторной генерации
заметок и нового PR. Finalize заново проверяет его CI, базу и дерево.
Наличие тега блокирует подготовку; более старые версии по-прежнему запрещены.
Prerelease и автоматический выбор номера не поддерживаются. Подготовка и finalize
исполняют доверенные исходники workflow, а не код из релизного PR. Релизный commit
может менять только два поля версии Gradle, десять файлов changelog (пять кодов APK на язык)
Expand All @@ -124,6 +130,10 @@ Prerelease и автоматический выбор номера не подд
Повтор использует результаты подготовки, не оплачивая ещё один ответ API.
Если merge уже прошёл, а создание тега не удалось, повтор finalize заново
проверит CI/дерево и создаст или проверит тег без повторного merge.
Это работает и после squash merge с той же версией. Если исправлен сам скрипт,
запускайте `release_finish` из актуального доверенного `main`: повтор старого
workflow использует прежний код. Новый запуск workflow с той же версией продолжает
слитый релизный PR, если тега ещё нет.

Если `main` или head изменились, старый finalize не должен одобрять новый код.
Осознанно обновите ветку так, чтобы единственный релизный commit был основан
Expand Down
38 changes: 30 additions & 8 deletions scripts/release_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,14 +428,35 @@ def prepare(version):
"main moved; restart from current main",
)
branch, tag = f"release/v{version}", f"v{version}"
require(
not command("git", "ls-remote", "--heads", "origin", f"refs/heads/{branch}"),
"Release branch already exists; inspect PR and rerun failed finalization job",
)
require(
not command("git", "ls-remote", "--tags", "origin", f"refs/tags/{tag}"),
"Release tag already exists",
)
if version == read_version(GRADLE.read_text())[0]:
owner = os.environ["GITHUB_REPOSITORY"].split("/")[0]
query = urlencode(
{
"state": "closed",
"base": "main",
"head": f"{owner}:{branch}",
"per_page": 100,
}
)
prs = [pr for pr in api(f"pulls?{query}") if pr.get("merged_at")]
require(len(prs) == 1, "Same version requires one merged release PR to resume")
pr = api(f"pulls/{prs[0]['number']}")
head = pr["head"]["sha"]
check_pr(pr, version, head)
require(pr["merged"], "Release PR is not merged")
emit(pr=pr["number"], head=head)
print(
f"Resuming {tag} from release PR #{pr['number']}; no notes or version changes"
)
return
require(
not command("git", "ls-remote", "--heads", "origin", f"refs/heads/{branch}"),
"Release branch already exists; inspect PR and rerun failed finalization job",
)
updated, code = bump_version(GRADLE.read_text(), version)
paths = notes_paths(code)
require(
Expand Down Expand Up @@ -617,9 +638,9 @@ def finish(version, number, head):
require(len(parts) == 2, "Missing Russian release notes")
validate_notes(dict(zip(LOCALES, parts)), 3000)
wait_ci(number, version, head, parent)
pr = api(f"pulls/{number}")
check_pr(pr, version, head)
if not pr["merged"]:
pr = api(f"pulls/{number}")
check_pr(pr, version, head)
require(
api("git/ref/heads/main")["object"]["sha"] == parent,
"main advanced while CI ran; refusing merge",
Expand All @@ -638,8 +659,9 @@ def finish(version, number, head):
merged = pr["merge_commit_sha"]
command("git", "fetch", "origin", "main")
require(
command("git", "show", "-s", "--format=%P", merged).split() == [parent, head],
"Release must have a merge commit with the verified base and head; refusing tag",
command("git", "show", "-s", "--format=%P", merged).split()
in ([parent, head], [parent]),
"Release commit must have the verified base (and head for a merge commit); refusing tag",
)
require(
command("git", "show", "-s", "--format=%T", merged)
Expand Down
77 changes: 72 additions & 5 deletions scripts/tests/test_release_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,11 @@ def test_ci_wait_uses_recorded_identity_when_merged_run_loses_pr_links(self):
m.wait_ci(7, "0.1.2", head, base)

def test_prepare_merge_and_retry_tag_with_real_git_and_fake_services(self):
for squash in (False, True):
with self.subTest(squash=squash):
self.prepare_merge_and_retry_tag(squash)

def prepare_merge_and_retry_tag(self, squash):
with (
tempfile.TemporaryDirectory() as tmp,
patch.dict(
Expand Down Expand Up @@ -807,6 +812,8 @@ def fake_api(path, payload=None, method=None):
},
)
return copy.deepcopy(pr)
if path.startswith("pulls?state=closed&"):
return [dict(copy.deepcopy(pr), merged_at="2026-10-24T17:56:57Z")]
if path == "pulls/7":
return copy.deepcopy(pr)
if path == "pulls/7/merge":
Expand All @@ -815,7 +822,17 @@ def fake_api(path, payload=None, method=None):
)
self.assertEqual("PUT", method)
git("switch", "-q", "main")
git("merge", "--no-ff", "-qm", "Release v0.1.2", pr["head"]["sha"])
if squash:
git("merge", "--squash", pr["head"]["sha"])
git("commit", "-qm", "Squashed release v0.1.2")
else:
git(
"merge",
"--no-ff",
"-qm",
"Release v0.1.2",
pr["head"]["sha"],
)
git("push", "-q", "origin", "main")
pr.update(
merged=True,
Expand Down Expand Up @@ -956,29 +973,79 @@ def advanced_main(path, payload=None, method=None):
):
m.finish("0.1.2", 7, head)
self.assertNotIn("pulls/7/merge", calls)
with patch.object(m, "wait_ci"):

def complete_ci(*args):
if squash and not pr["merged"]:
# A user can squash-merge while finalization waits for CI.
fake_api(
"pulls/7/merge",
{"sha": head, "merge_method": "merge"},
"PUT",
)

with patch.object(m, "wait_ci", side_effect=complete_ci):
m.finish("0.1.2", 7, head)
self.assertEqual(
pr["merge_commit_sha"],
git("rev-parse", "refs/tags/v0.1.2", cwd=remote),
)
self.assertNotEqual(head, pr["merge_commit_sha"])
self.assertEqual(
[base, head],
[base] if squash else [base, head],
git(
"show", "-s", "--format=%P", pr["merge_commit_sha"]
).split(),
)
merged_commit = pr["merge_commit_sha"]
pr["merge_commit_sha"] = head
pr["merge_commit_sha"] = base
with self.assertRaisesRegex(RuntimeError, "verified base"):
m.finish("0.1.2", 7, head)
# Even a valid parent cannot authorize untested content.
pr["merge_commit_sha"] = git(
"commit-tree",
f"{base}^{{tree}}",
"-p",
base,
"-m",
"Wrong release tree",
)
with self.assertRaisesRegex(
RuntimeError, "must have a merge commit"
RuntimeError, "Merged tree differs"
):
m.finish("0.1.2", 7, head)
pr["merge_commit_sha"] = merged_commit
m.finish("0.1.2", 7, head)
self.assertEqual(1, calls.count("pulls/7/merge"))
self.assertEqual(1, calls.count("git/refs"))
# Same-version preparation resumes the original PR only without a tag.
with self.assertRaisesRegex(
RuntimeError, "Release tag already exists"
):
m.prepare("0.1.2")
git("update-ref", "-d", "refs/tags/v0.1.2", cwd=remote)
before = git("rev-parse", "HEAD")
with (
patch.object(m, "generate_notes") as generate,
patch.object(m, "emit") as emit,
):
m.prepare("0.1.2")
emit.assert_called_once_with(pr=7, head=head)
generate.assert_not_called()
self.assertEqual(before, git("rev-parse", "HEAD"))
self.assertEqual("", git("status", "--porcelain"))
with patch.object(
m,
"api",
side_effect=lambda path, *args, **kwargs: (
[]
if path.startswith("pulls?")
else fake_api(path, *args, **kwargs)
),
):
with self.assertRaisesRegex(
RuntimeError, "one merged release PR"
):
m.prepare("0.1.2")
# Never move an existing tag, even on a successful rerun.
git("update-ref", "refs/tags/v0.1.2", base, cwd=remote)
with self.assertRaisesRegex(RuntimeError, "elsewhere"):
Expand Down
Loading