diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index cf58162..9a45f85 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 5b83301..3656474 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index c9433a0..702f10a 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -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`. | diff --git a/docs/en/release-automation.md b/docs/en/release-automation.md index 927f938..5ff6e77 100644 --- a/docs/en/release-automation.md +++ b/docs/en/release-automation.md @@ -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 @@ -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 diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index 691bee3..2ef102c 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -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`. | diff --git a/docs/ru/release-automation.md b/docs/ru/release-automation.md index a86cbc7..5c8bc28 100644 --- a/docs/ru/release-automation.md +++ b/docs/ru/release-automation.md @@ -101,8 +101,10 @@ 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`. @@ -110,6 +112,10 @@ workflow с прямым merge не поддерживается. Правила не переставляет тег, указывающий на другой commit. Новая версия должна быть выше версии в коде и последнего стабильного тега. +Если запрошена текущая версия и её удалённого тега ещё нет, подготовка продолжает +единственный слитый релизный PR: без изменения versionCode, повторной генерации +заметок и нового PR. Finalize заново проверяет его CI, базу и дерево. +Наличие тега блокирует подготовку; более старые версии по-прежнему запрещены. Prerelease и автоматический выбор номера не поддерживаются. Подготовка и finalize исполняют доверенные исходники workflow, а не код из релизного PR. Релизный commit может менять только два поля версии Gradle, десять файлов changelog (пять кодов APK на язык) @@ -124,6 +130,10 @@ Prerelease и автоматический выбор номера не подд Повтор использует результаты подготовки, не оплачивая ещё один ответ API. Если merge уже прошёл, а создание тега не удалось, повтор finalize заново проверит CI/дерево и создаст или проверит тег без повторного merge. +Это работает и после squash merge с той же версией. Если исправлен сам скрипт, +запускайте `release_finish` из актуального доверенного `main`: повтор старого +workflow использует прежний код. Новый запуск workflow с той же версией продолжает +слитый релизный PR, если тега ещё нет. Если `main` или head изменились, старый finalize не должен одобрять новый код. Осознанно обновите ветку так, чтобы единственный релизный commit был основан diff --git a/scripts/release_automation.py b/scripts/release_automation.py index 52b1f7a..78917f9 100644 --- a/scripts/release_automation.py +++ b/scripts/release_automation.py @@ -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( @@ -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", @@ -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) diff --git a/scripts/tests/test_release_automation.py b/scripts/tests/test_release_automation.py index 0dcaf3c..d854e1d 100644 --- a/scripts/tests/test_release_automation.py +++ b/scripts/tests/test_release_automation.py @@ -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( @@ -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": @@ -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, @@ -956,7 +973,17 @@ 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"], @@ -964,21 +991,61 @@ def advanced_main(path, payload=None, method=None): ) 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"):