From c0d8951677c2fea054d6d885397b3f82e59a7746 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:58:15 +0200 Subject: [PATCH] fix(release): centralize pubspec version parsing --- .github/workflows/release.yml | 6 +-- scripts/pubspec_version.py | 74 +++++++++++++++++++++++++++++++++ scripts/release.sh | 2 +- scripts/test_pubspec_version.py | 69 ++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 scripts/pubspec_version.py create mode 100644 scripts/test_pubspec_version.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d97de061..d5845617 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,10 +32,8 @@ jobs: - name: Extract current build number id: get_build run: | - # Extract current build number from pubspec.yaml - CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //') - CURRENT_BUILD=$(echo "$CURRENT_VERSION" | cut -d'+' -f2) - NEW_BUILD=$((CURRENT_BUILD + 1)) + CURRENT_BUILD=$(python3 scripts/pubspec_version.py --build-number pubspec.yaml) + NEW_BUILD=$((10#$CURRENT_BUILD + 1)) echo "current_build=$CURRENT_BUILD" >> $GITHUB_OUTPUT echo "new_build=$NEW_BUILD" >> $GITHUB_OUTPUT diff --git a/scripts/pubspec_version.py b/scripts/pubspec_version.py new file mode 100644 index 00000000..41bae538 --- /dev/null +++ b/scripts/pubspec_version.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Read and validate the top-level version from a Flutter pubspec.""" + +import argparse +from pathlib import Path +import re +import sys + + +_TOP_LEVEL_VERSION = re.compile(r"^version:[ \t]*(.*)$") +_VERSION = re.compile( + r"(?P" + r"(?:0|[1-9][0-9]*)\." + r"(?:0|[1-9][0-9]*)\." + r"(?:0|[1-9][0-9]*)\+" + r"(?P[0-9]+)" + r")" +) + + +def parse_pubspec_version(contents: str) -> tuple[str, str]: + values = [] + for line in contents.splitlines(): + match = _TOP_LEVEL_VERSION.match(line) + if match: + value = re.sub(r"[ \t]+#.*$", "", match.group(1)).strip() + values.append(value) + + if len(values) != 1: + raise ValueError( + f"expected one top-level 'version:' field, found {len(values)}" + ) + + match = _VERSION.fullmatch(values[0]) + if not match: + raise ValueError( + "top-level version must use major.minor.patch+numeric-build syntax; " + f"got {values[0]!r}" + ) + + return match.group("version"), match.group("build") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Read and validate the top-level pubspec version." + ) + parser.add_argument( + "pubspec", + nargs="?", + type=Path, + default=Path("pubspec.yaml"), + help="path to pubspec.yaml (default: ./pubspec.yaml)", + ) + parser.add_argument( + "--build-number", + action="store_true", + help="print only the numeric build metadata", + ) + args = parser.parse_args() + + try: + contents = args.pubspec.read_text(encoding="utf-8") + version, build = parse_pubspec_version(contents) + except (OSError, UnicodeError, ValueError) as error: + print(f"Error: {args.pubspec}: {error}", file=sys.stderr) + return 1 + + print(build if args.build_number else version) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release.sh b/scripts/release.sh index 7060c120..34a89d80 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -22,7 +22,7 @@ create_changelogs() { fi local version_code - version_code=$(grep -E 'version:' "$PROJECT_ROOT/pubspec.yaml" | sed -E 's/.*\+([0-9]+).*/\1/') + version_code=$(python3 "$SCRIPT_DIR/pubspec_version.py" --build-number "$PROJECT_ROOT/pubspec.yaml") local prompt="Below is a changelog for a cross-platform Flutter app (iOS, Android, macOS, Linux, Windows). Return ONLY the entries relevant to the given platform. Keep the same format (section headers + bullet points). If a section has no relevant entries, omit it entirely. If an entry is not platform-specific, include it. You MUST stay under the character limit. Aggressively drop less important entries and consolidate similar ones to fit. Count your output characters before responding. Output nothing else." local notes diff --git a/scripts/test_pubspec_version.py b/scripts/test_pubspec_version.py new file mode 100644 index 00000000..e70df927 --- /dev/null +++ b/scripts/test_pubspec_version.py @@ -0,0 +1,69 @@ +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from pubspec_version import parse_pubspec_version + + +SCRIPT = SCRIPT_DIR / "pubspec_version.py" + + +class ParsePubspecVersionTest(unittest.TestCase): + def test_ignores_duplicate_nested_version_keys(self) -> None: + contents = """\ +name: example +dependencies: + first: + version: 9.9.9+999 +version: 2.8.0+119 +metadata: + version: malformed +""" + + self.assertEqual(parse_pubspec_version(contents), ("2.8.0+119", "119")) + + def test_rejects_malformed_versions(self) -> None: + for value in ("2.8", "2.8.0", "02.8.0+1", "2.8.0+build.1"): + with self.subTest(value=value), self.assertRaises(ValueError): + parse_pubspec_version(f"version: {value}\n") + + def test_rejects_missing_top_level_version(self) -> None: + contents = """\ +name: example +dependency: + version: 2.8.0+119 +""" + + with self.assertRaisesRegex(ValueError, "found 0"): + parse_pubspec_version(contents) + + def test_accepts_numeric_build_metadata(self) -> None: + self.assertEqual( + parse_pubspec_version("version: 10.20.30+0007 # release build\n"), + ("10.20.30+0007", "0007"), + ) + + def test_cli_prints_build_number(self) -> None: + with tempfile.TemporaryDirectory() as directory: + pubspec = Path(directory) / "pubspec.yaml" + pubspec.write_text("version: 1.2.3+456\n", encoding="utf-8") + + result = subprocess.run( + [sys.executable, str(SCRIPT), "--build-number", str(pubspec)], + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout, "456\n") + self.assertEqual(result.stderr, "") + + +if __name__ == "__main__": + unittest.main()