From 5bafc49ce5c429019a5d0bdc6e2505a858760c1c Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 27 Oct 2026 20:47:42 +0300 Subject: [PATCH] Retry transient GitHub Check publication updates --- .github/workflows/test-reports.yml | 27 ++++++ docs/en/release-testing.md | 2 + docs/ru/release-testing.md | 2 + scripts/tests/test_test_report_retry.py | 116 ++++++++++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 scripts/tests/test_test_report_retry.py diff --git a/.github/workflows/test-reports.yml b/.github/workflows/test-reports.yml index d931f965..be8d585d 100644 --- a/.github/workflows/test-reports.yml +++ b/.github/workflows/test-reports.yml @@ -29,7 +29,34 @@ jobs: timeout-minutes: 5 steps: # Parse uploaded data only; never check out or run code from the tested PR. + # Inline trusted code also works for fork reports without checking out PR code. + - name: Retry transient GitHub Check updates + run: | + cat > "$RUNNER_TEMP/test-report-check-retry.cjs" <<'JS' + const {setTimeout: sleep} = require('node:timers/promises'); + const fetch = globalThis.fetch; + const api = new URL(process.env.GITHUB_API_URL); + globalThis.fetch = async (input, options) => { + const url = new URL(input instanceof Request ? input.url : input); + // Octokit sends JSON strings; do not replay streams or Check creation. + if (url.origin !== api.origin || options?.method !== 'PATCH' || + typeof options.body !== 'string' || + !/^\/repos\/[^/]+\/[^/]+\/check-runs\/\d+$/.test(url.pathname)) { + return fetch(input, options); + } + const delays = [1000, 3000]; + for (let attempt = 0; ; attempt++) { + const response = await fetch(input, options); + if (response.status !== 404 || attempt === delays.length) return response; + await response.body?.cancel(); + console.warn(`GitHub Check update returned 404; retry ${attempt + 1}/2 in ${delays[attempt]}ms`); + await sleep(delays[attempt], undefined, {signal: options.signal}); + } + }; + JS - name: Publish JUnit results in GitHub Checks + env: + NODE_OPTIONS: --require=${{ runner.temp }}/test-report-check-retry.cjs uses: dorny/test-reporter@d1b5dff021aa968b288b02285a8bf47471c5928a # v3 with: artifact: ${{ inputs.artifact || '/^junit-(.+)$/' }} diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index 4d4b2032..819df6cd 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -46,6 +46,8 @@ whether to scroll; a runtime state update alone does not prove the matching cont before tests start. A failed download before the runner is infrastructure failure, not a test verdict; inspect it before a manual retry. Do not disable checksum/certificate verification to unblock CI. - JUnit reports publish per suite immediately. A missing report or skipped suite is not a pass. + Check publication retries only a 404 on the same Check update, twice after 1 and 3 seconds; + it never reruns tests or creates another Check. An exhausted retry still fails publication. PR APKs use no release secrets. Commands, artifacts and emulator prerequisites: [Fastlane](fastlane.md). The device suites exercise real TUN/JNI TCP/UDP, HTTPS/SOCKS5/MASQUE, HTTPS preference/fallback, diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index d50219ae..e5248231 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -47,6 +47,8 @@ Native-тесты используют управляемые серверы, н тестов. Ошибка скачивания до runner — сбой инфраструктуры, не результат теста; перед ручным повтором изучите причину. Не отключайте проверку checksum/сертификатов ради зелёного CI. - JUnit публикуется отдельно по завершении набора. Отсутствие отчёта или пропуск не означает PASS. + Публикация повторяет только обновление того же Check при 404: дважды через 1 и 3 секунды, + без перезапуска тестов и создания нового Check. После исчерпания повторов публикация падает. Релизные секреты недоступны PR. Команды, артефакты и требования эмулятора: [Fastlane](fastlane.md). Device-наборы проверяют реальный TUN/JNI TCP/UDP, HTTPS/SOCKS5/MASQUE, HTTPS preference/fallback, diff --git a/scripts/tests/test_test_report_retry.py b/scripts/tests/test_test_report_retry.py new file mode 100644 index 00000000..02c03913 --- /dev/null +++ b/scripts/tests/test_test_report_retry.py @@ -0,0 +1,116 @@ +import json +import subprocess +import textwrap +import unittest +from pathlib import Path + +WORKFLOW = Path(__file__).parents[2] / ".github/workflows/test-reports.yml" +SCRIPT = textwrap.dedent( + WORKFLOW.read_text().split("<<'JS'\n", 1)[1].split("\n JS", 1)[0] +) +HARNESS = """ +const vm = require('node:vm'); +const script = JSON.parse(process.argv[1]); +const options = JSON.parse(process.argv[2]); +const result = {calls: [], delays: [], cancelled: 0, warnings: []}; +const statuses = options.statuses || [200]; +const globals = {fetch: async (url, init) => { + result.calls.push({url, ...init}); + if (options.error) throw new Error(options.error); + return {status: statuses.shift(), body: {cancel: async () => result.cancelled++}}; +}}; +vm.runInNewContext(script, { + globalThis: globals, URL, Request, process: {env: {GITHUB_API_URL: 'https://api.github.com'}}, + require: name => { + if (name !== 'node:timers/promises') throw new Error('Unexpected dependency'); + return {setTimeout: async delay => result.delays.push(delay)}; + }, + console: {warn: warning => result.warnings.push(warning)} +}); +(async () => { + try { + const response = await globals.fetch( + options.url || 'https://api.github.com/repos/owner/repo/check-runs/123', + {method: options.method || 'PATCH', body: options.body ?? '{"conclusion":"success"}'} + ); + result.status = response.status; + } catch (error) { result.error = error.message; } + process.stdout.write(JSON.stringify(result)); +})(); +""" + + +class TestReportRetryTests(unittest.TestCase): + def run_retry(self, **options): + return json.loads( + subprocess.check_output( + ["node", "-e", HARNESS, json.dumps(SCRIPT), json.dumps(options)], + text=True, + timeout=10, + ) + ) + + def test_transient_404_retries_the_same_update_without_creating_a_check(self): + result = self.run_retry(statuses=[404, 404, 200]) + self.assertEqual(result["status"], 200) + self.assertEqual(result["delays"], [1000, 3000]) + self.assertEqual(result["cancelled"], 2) + self.assertEqual(len(result["calls"]), 3) + self.assertTrue(all(c == result["calls"][0] for c in result["calls"])) + self.assertEqual(result["calls"][0]["method"], "PATCH") + + def test_a_failed_test_result_is_not_changed_to_success(self): + body = '{"status":"completed","conclusion":"failure"}' + result = self.run_retry(statuses=[404, 200], body=body) + self.assertEqual(result["status"], 200) + self.assertTrue(all(c["body"] == body for c in result["calls"])) + + def test_exhaustion_preserves_the_final_404(self): + result = self.run_retry(statuses=[404, 404, 404, 200]) + self.assertEqual(result["status"], 404) + self.assertEqual(len(result["calls"]), 3) + self.assertEqual(result["delays"], [1000, 3000]) + + def test_other_responses_and_network_errors_are_not_retried(self): + for status in [200, 401, 403, 422, 500]: + with self.subTest(status=status): + result = self.run_retry(statuses=[status]) + self.assertEqual(result["status"], status) + self.assertEqual(len(result["calls"]), 1) + self.assertEqual(result["delays"], []) + result = self.run_retry(error="aborted") + self.assertEqual(result["error"], "aborted") + self.assertEqual(len(result["calls"]), 1) + self.assertEqual(result["delays"], []) + + def test_creation_other_endpoints_and_non_replayable_bodies_are_not_retried(self): + for options in [ + {"method": "POST", "url": "https://api.github.com/repos/o/r/check-runs"}, + {"method": "GET"}, + {"url": "https://api.github.com/repos/o/r/issues/123"}, + {"url": "https://other.example/repos/o/r/check-runs/123"}, + {"body": {"stream": True}}, + ]: + with self.subTest(options=options): + result = self.run_retry(statuses=[404, 200], **options) + self.assertEqual(result["status"], 404) + self.assertEqual(len(result["calls"]), 1) + self.assertEqual(result["delays"], []) + + def test_preload_is_scoped_to_check_publication(self): + workflow = WORKFLOW.read_text() + publication, summary = workflow.split( + " - name: Add collapsible test groups to Actions Summary", 1 + ) + self.assertIn( + "NODE_OPTIONS: --require=${{ runner.temp }}/test-report-check-retry.cjs", + publication, + ) + self.assertIn('cat > "$RUNNER_TEMP/test-report-check-retry.cjs"', publication) + self.assertNotIn("NODE_OPTIONS", summary) + self.assertNotIn("continue-on-error", workflow) + self.assertNotIn("actions/checkout", workflow) + + +if __name__ == "__main__": + unittest.main()