From 8f356eaf1e5fbb690c86220d0da7df0a358d3d7b Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Sat, 24 Oct 2026 21:07:10 +0300 Subject: [PATCH] Expose safe release API errors and document tag token permissions --- docs/en/release-automation.md | 11 +++++++- docs/ru/release-automation.md | 11 +++++++- scripts/release_automation.py | 20 ++++++++++++--- scripts/tests/test_release_automation.py | 32 ++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/docs/en/release-automation.md b/docs/en/release-automation.md index 5ff6e77..bb66d1c 100644 --- a/docs/en/release-automation.md +++ b/docs/en/release-automation.md @@ -17,7 +17,16 @@ In repository **Settings → Secrets and variables → Actions**, configure: | --- | --- | --- | | Secret | `OPENAI_API_KEY` | OpenAI API key, used only for generating EN/RU GitHub and F-Droid notes. | | Variable / Secret | `OPENAI_RELEASE_MODEL` | A model available to your API project that supports Responses API Structured Outputs, for example `gpt-6.1-sol`. Variables take priority over Secrets if both are set. No implicit model fallback. | -| Secret | `RELEASE_BOT_TOKEN` | Fine-grained PAT limited to this repository: Contents read/write, Pull requests read/write, Actions read. | +| Secret | `RELEASE_BOT_TOKEN` | Fine-grained PAT limited to this repository: Contents read/write, Workflows read/write, Pull requests read/write, Actions read. | + +Tag creation through the Git references API can also require Workflows write permission, +including when tagging an earlier commit after `main` advances. For a classic PAT, use +`repo` and `workflow` scopes. Check the token in GitHub **Settings → Developer settings → +Personal access tokens**; after replacing it, update the repository's `RELEASE_BOT_TOKEN` +secret. The helper reports the failed API method, endpoint and HTTP status without dumping +response bodies. HTTP 403 on `POST .../git/refs` requires checking these permissions and +repository tag rules; a generic API error alone does not establish the cause. +[GitHub reference API permissions](https://docs.github.com/en/rest/git/refs#create-a-reference). The tag workflow also requires `VIRUSTOTAL_API_KEY`. After signing, it submits all five public release APKs to VirusTotal, including the universal APK via the large-file diff --git a/docs/ru/release-automation.md b/docs/ru/release-automation.md index 5c8bc28..33ac492 100644 --- a/docs/ru/release-automation.md +++ b/docs/ru/release-automation.md @@ -17,7 +17,16 @@ workflow нужно явно запустить с новой версией. | --- | --- | --- | | Secret | `OPENAI_API_KEY` | Ключ OpenAI API, только для генерации EN/RU текстов GitHub и F-Droid. | | Variable / Secret | `OPENAI_RELEASE_MODEL` | Доступная вашему API-проекту модель с Responses API Structured Outputs, например `gpt-6.1-sol`. Если заданы оба варианта, Variables имеют приоритет над Secrets. Неявного выбора другой модели нет. | -| Secret | `RELEASE_BOT_TOKEN` | Fine-grained PAT только для этого репозитория: Contents read/write, Pull requests read/write, Actions read. | +| Secret | `RELEASE_BOT_TOKEN` | Fine-grained PAT только для этого репозитория: Contents read/write, Workflows read/write, Pull requests read/write, Actions read. | + +Создание тега через Git references API также может требовать Workflows write, +в том числе при тегировании предыдущего commit после продвижения `main`. Для classic PAT +нужны scope `repo` и `workflow`. Проверьте токен в GitHub **Settings → Developer settings → +Personal access tokens**; при замене обновите секрет репозитория `RELEASE_BOT_TOKEN`. +Скрипт показывает метод, endpoint и HTTP-статус сбоя без вывода тела ответа. +HTTP 403 на `POST .../git/refs` требует проверки этих прав и правил тегов репозитория; +общая ошибка API сама по себе не доказывает причину. +[Права GitHub reference API](https://docs.github.com/en/rest/git/refs#create-a-reference). Workflow по тегу также требует `VIRUSTOTAL_API_KEY`. После подписи все пять публичных релизных APK отправляются в VirusTotal; для universal используется загрузка больших diff --git a/scripts/release_automation.py b/scripts/release_automation.py index 78917f9..35e3e76 100644 --- a/scripts/release_automation.py +++ b/scripts/release_automation.py @@ -41,10 +41,22 @@ def command(*args, input_text=None): result = subprocess.run( args, input=input_text, capture_output=True, text=True, timeout=60, env=env ) - require( - result.returncode == 0, - f"{args[0]} {args[1]} failed; inspect the remote state before retrying", - ) + if result.returncode: + operation = f"{args[0]} {args[1]}" + hint = "inspect the remote state before retrying" + if args[:2] == ("gh", "api"): + method = args[args.index("--method") + 1] + operation = f"GitHub API {method} {args[2].split('?')[0]}" + status = re.search(r"\bHTTP ([1-5][0-9]{2})\b", result.stderr) + if status: + operation += f" (HTTP {status[1]})" + if status and status[1] == "403" and args[2].endswith("/git/refs"): + hint = ( + "check RELEASE_BOT_TOKEN Contents/Workflows write permissions " + "and repository tag rules" + ) + # API responses and subprocess output may contain credentials or release evidence. + raise RuntimeError(f"{operation} failed; {hint}") return result.stdout.strip() diff --git a/scripts/tests/test_release_automation.py b/scripts/tests/test_release_automation.py index d854e1d..57cbff3 100644 --- a/scripts/tests/test_release_automation.py +++ b/scripts/tests/test_release_automation.py @@ -8,6 +8,7 @@ import unittest import urllib.request from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch sys.path.insert(0, str(Path(__file__).parents[1])) @@ -52,6 +53,37 @@ def test_api_mutations_use_explicit_http_method_and_structured_stdin(self): input_text=json.dumps({"ref": "refs/tags/v1.0.0", "sha": "a" * 40}), ) + def test_api_failure_identifies_request_and_status_without_leaking_output(self): + for status in ("403", "422", None): + with ( + self.subTest(status=status), + patch.dict( + os.environ, {"GITHUB_REPOSITORY": "owner/repo", "GH_DEBUG": "api"} + ), + patch.object( + m.subprocess, + "run", + return_value=SimpleNamespace( + returncode=1, + stdout="synthetic-secret-response", + stderr="synthetic-secret-token " + + (f"(HTTP {status})" if status else "network failure"), + ), + ) as run, + ): + with self.assertRaises(RuntimeError) as error: + m.api("git/refs", {"ref": "refs/tags/v1.2.3", "sha": "a" * 40}) + message = str(error.exception) + self.assertIn("GitHub API POST repos/owner/repo/git/refs", message) + self.assertNotIn("synthetic-secret", message) + self.assertNotIn("GH_DEBUG", run.call_args.kwargs["env"]) + if status: + self.assertIn(f"HTTP {status}", message) + if status == "403": + self.assertIn("Contents/Workflows write", message) + else: + self.assertNotIn("Contents/Workflows write", message) + def test_version_increment_preserves_fdroid_abi_layout(self): updated, code = m.bump_version(SOURCE, "0.1.2") self.assertEqual(15000, code)