Skip to content

Commit 87c3a91

Browse files
committed
test(ci): cover the release versionCode gate, run Python through uv
1 parent 7e37123 commit 87c3a91

6 files changed

Lines changed: 144 additions & 20 deletions

File tree

.github/workflows/build-flutter-app.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,18 @@ jobs:
106106
- name: Derive Android versionCode from the release versionName
107107
run: dart run tool/set_version_code.dart
108108

109+
# uv pins the interpreter instead of inheriting whatever python3 the
110+
# runner image happens to ship, so this gate behaves the same here as it
111+
# does locally (`uv run --python 3.12 scripts/ci/check-release-version.py`).
112+
- name: Set up uv
113+
uses: astral-sh/setup-uv@v6
114+
with:
115+
python-version: '3.12'
116+
109117
- name: Guard versionCode against published releases
110118
env:
111119
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
112-
run: python3 scripts/ci/check-release-version.py
120+
run: uv run --python 3.12 scripts/ci/check-release-version.py
113121

114122
- name: Restore the existing release keystore outside the checkout
115123
env:

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ jobs:
2020
- run: flutter analyze
2121
- run: flutter test
2222

23+
# The release gate is Python, so it is not covered by `flutter test`.
24+
# It decides whether a versionCode may ship, and a regression there
25+
# either blocks every release or lets a non-increasing code through —
26+
# which Android then refuses on install. uv pins the interpreter so this
27+
# behaves the same locally and on the runner.
28+
- name: Set up uv
29+
uses: astral-sh/setup-uv@v6
30+
with:
31+
python-version: '3.12'
32+
- name: Test the release versionCode gate
33+
run: uv run --python 3.12 scripts/ci/check_release_version_test.py
34+
2335
# Hex-literal audit. Fails the PR if any 0x[0-9a-fA-F]{4,} that
2436
# looks like a feature ID lives outside the bundled dispatch
2537
# table (android/app/src/main/assets/offline/car_table.textproto). Color literals,

CHANGELOG.md

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,13 @@
11
# Changelog
22

3-
Release entries below are generated by release-please from Conventional Commits.
4-
Merging the Release PR it opens is what tags a version and builds the signed
5-
APK — see [RELEASE.md](RELEASE.md).
3+
This repository starts with the standalone codebase. Historical release notes are retained with the private archive of the previous repository. Future release entries are managed by release-please from Conventional Commits.
64

75
## [Unreleased]
86

9-
iLINK's first release has not been cut yet. The codebase runs locally today:
7+
- Run core dashboard, workflow, catalog and English voice features locally without a payment system, login or hosted backend.
8+
- Preserve the original Android release signer and use an explicit versionCode independent of Git history.
9+
- Discover opted-in future updates through GitHub Releases; existing backend-based installations need one manual migration update.
10+
- Enforce mini-app local permissions and WebView boundaries, and handle local voice capture errors and cancellation safely.
11+
- Adopt the owner-approved MIT License and add standalone development/contribution guidance.
1012

11-
- Dashboard, workflows, catalogs and English voice work with no account, no
12-
payment system and no hosted backend.
13-
- Every outbound connection sits behind an optional-service switch that ships
14-
off; there is no analytics SDK or telemetry.
15-
- The bundled radio directory browses and searches 212 countries offline.
16-
- Mini-app permissions are bound to the app ID and the exact bundle digest, and
17-
WebView egress is restricted to declared origins.
18-
- Updates are discovered through GitHub Releases only after the driver opts in,
19-
and are verified by package, version, hash and signer before install.
20-
- MIT licensed, with third-party notices for the bundled fonts, speech model
21-
and radio directory.
22-
23-
Physical head-unit acceptance is still open — see
24-
[final verification](docs/offline-first/final-verification.md) for what is and
25-
is not proven.
13+
See the [standalone cleanup record](docs/offline-first/standalone-cleanup.md) and [final verification](docs/offline-first/final-verification.md) for the transformation detail, test evidence and remaining physical-device/publication acceptance work. This entry does not announce a published release.

CONTRIBUTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Use a focused branch such as `fix/local-model-loading`, `feat/radio-search` or `
1818

1919
## Flutter and Android setup
2020

21+
- Install [uv](https://docs.astral.sh/uv/) for the one Python gate: `uv run --python 3.12 scripts/ci/check_release_version_test.py`. uv fetches the interpreter itself, so no separate Python install is needed.
2122
- Install **Flutter 3.47.2 stable**, which includes Dart. Use the version pinned in the [setup action](.github/actions/setup-flutter/action.yml).
2223
- Install Android Studio or the Android command-line tools, an Android SDK and platform tools, and a **JDK 17** environment for Gradle.
2324
- Install Android SDK Platform **36**, **NDK 28.2.13676358** and **CMake 3.22.1** through the SDK Manager. Compile SDK follows the pinned Flutter SDK; NDK/CMake and Java versions are set in [Android build configuration](android/app/build.gradle.kts).
@@ -55,6 +56,7 @@ flutter analyze
5556
flutter test
5657
dart run tool/audit_hex.dart
5758
dart run tool/validate_prod_config.dart
59+
uv run --python 3.12 scripts/ci/check_release_version_test.py
5860
```
5961

6062
The format command can update files and reports failure when changes were necessary; review those changes and run it again. The hex audit checks that vehicle feature IDs remain in their approved table locations. The configuration validator checks the production definitions without connecting to a backend. Do not weaken either check to hide a failure.

docs/ORIENTATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ flutter analyze
7777
flutter test
7878
dart run tool/audit_hex.dart # vehicle feature IDs must stay in the dispatch table
7979
dart run tool/validate_prod_config.dart
80+
uv run --python 3.12 scripts/ci/check_release_version_test.py # release gate (needs uv)
8081
```
8182

8283
For native changes, also run the JVM tests from `android/`:
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Tests for check-release-version.py, the gate that protects every release.
2+
3+
Run with: uv run --python 3.12 scripts/ci/check_release_version_test.py
4+
5+
Standard library only, so it needs nothing but an interpreter. The GitHub API
6+
is stubbed by replacing the module's `gh` helper, which keeps the test offline
7+
and lets it assert the one behaviour that matters: a versionCode that does not
8+
move forward must fail the build, because Android will refuse the update
9+
anyway.
10+
"""
11+
12+
import importlib.util
13+
import json
14+
import os
15+
import tempfile
16+
import unittest
17+
from pathlib import Path
18+
19+
MODULE = Path(__file__).with_name('check-release-version.py')
20+
21+
22+
def load_module():
23+
spec = importlib.util.spec_from_file_location('check_release_version', MODULE)
24+
module = importlib.util.module_from_spec(spec)
25+
spec.loader.exec_module(module)
26+
return module
27+
28+
29+
def stub_gh(published_code):
30+
"""Fake `gh api`.
31+
32+
`published_code=None` means a repository with no releases at all, which is
33+
what a first release actually faces. Any integer means one published
34+
release whose metadata asset reports that versionCode.
35+
"""
36+
37+
def gh(*args):
38+
if 'releases/assets/' in args[0]:
39+
return json.dumps({
40+
'versionName': 'x',
41+
'versionCode': published_code,
42+
'sha256': 'y',
43+
'sizeBytes': 1,
44+
})
45+
if published_code is None:
46+
return json.dumps([[]])
47+
return json.dumps([[{
48+
'assets': [{'name': 'ilink-release.json', 'size': 120, 'id': 42}],
49+
}]])
50+
51+
return gh
52+
53+
54+
class CheckReleaseVersion(unittest.TestCase):
55+
def setUp(self):
56+
self._cwd = os.getcwd()
57+
self._tmp = tempfile.TemporaryDirectory()
58+
os.chdir(self._tmp.name)
59+
os.environ['GITHUB_REPOSITORY'] = 'i99dev/ilink'
60+
self.addCleanup(self._restore)
61+
62+
def _restore(self):
63+
os.chdir(self._cwd)
64+
self._tmp.cleanup()
65+
66+
def run_guard(self, version, published_code=None):
67+
Path('pubspec.yaml').write_text(
68+
f'name: ilink\nversion: {version}\n', encoding='utf-8'
69+
)
70+
module = load_module()
71+
module.gh = stub_gh(published_code)
72+
module.main()
73+
74+
def assert_rejected(self, version, published_code=None, contains=''):
75+
with self.assertRaises(SystemExit) as caught:
76+
self.run_guard(version, published_code)
77+
if contains:
78+
self.assertIn(contains, str(caught.exception))
79+
80+
def test_accepts_a_code_above_the_published_one(self):
81+
self.run_guard('3.23.0-b+3023000', published_code=3022000)
82+
83+
def test_rejects_an_unchanged_code(self):
84+
# The exact release-#2 failure: release-please bumps the versionName
85+
# but not the build metadata, so without tool/set_version_code.dart the
86+
# second release would carry the first one's versionCode.
87+
self.assert_rejected(
88+
'3.22.0-b+3022000',
89+
published_code=3022000,
90+
contains='must exceed previous release',
91+
)
92+
93+
def test_rejects_a_downgrade(self):
94+
self.assert_rejected('3.22.0-b+3021999', published_code=3022000)
95+
96+
def test_requires_explicit_build_metadata(self):
97+
self.assert_rejected('3.22.0-b', contains='explicit positive integer')
98+
99+
def test_rejects_zero(self):
100+
self.assert_rejected('3.22.0-b+0', contains='explicit positive integer')
101+
102+
def test_accepts_a_small_code_on_a_fresh_repository(self):
103+
# iLINK has its own applicationId and inherits no continuity floor, so
104+
# a 1.0.0 first release must be allowed.
105+
self.run_guard('1.0.0+1000000')
106+
107+
def test_rejects_a_repository_that_is_not_owner_slash_name(self):
108+
os.environ['GITHUB_REPOSITORY'] = 'i99dev/ilink; rm -rf /'
109+
self.assert_rejected('3.22.0-b+3022000', contains='Invalid repository')
110+
111+
112+
if __name__ == '__main__':
113+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)