From f3a1eed0207361fbbc0702ad083cd74f8a6b62fc Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Tue, 27 Oct 2026 14:24:10 -0400 Subject: [PATCH 1/2] fix(releases): repair changelog and release comparison links Generate future release notes from merged pull request titles with the correct tag prefix, and validate release metadata before publication. Include each version's comparison link in its GitHub release description. Repair historical comparisons, restore the complete 2.2.0 release notes, and document the release process. Add regression tests for invalid release metadata and refresh the shared pull request notification workflow. --- .github/scripts/release.py | 154 ++++++++++++++++++++++++++ .github/scripts/test_release.py | 134 ++++++++++++++++++++++ .github/workflows/pull-request.yml | 2 +- .github/workflows/release-draft.yml | 32 ++++-- .github/workflows/release-publish.yml | 24 +++- .github/workflows/test.yml | 4 + CHANGELOG.md | 34 +++--- MIGRATING.md | 2 +- README.md | 2 +- RELEASING.md | 36 ++++++ 10 files changed, 394 insertions(+), 30 deletions(-) create mode 100644 .github/scripts/release.py create mode 100644 .github/scripts/test_release.py create mode 100644 RELEASING.md diff --git a/.github/scripts/release.py b/.github/scripts/release.py new file mode 100644 index 0000000..fa58fd9 --- /dev/null +++ b/.github/scripts/release.py @@ -0,0 +1,154 @@ +"""Validate release metadata and prepare the GitHub release description.""" + +import argparse +import re +import subprocess +from pathlib import Path + +REPOSITORY = "https://github.com/mParticle/mparticle-flutter-sdk" +VERSION = r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?" +HEADING = re.compile( + rf"(?:\[(Unreleased|{VERSION})\]|({VERSION}))(?: - \d{{4}}-\d{{2}}-\d{{2}})?", + re.IGNORECASE, +) +REFERENCE = re.compile(r"^\[([^\]]+)\]:\s+(\S+)\s*$", re.MULTILINE) + + +def read_changelog(root): + text = (root / "CHANGELOG.md").read_text() + headings = list(re.finditer(r"^## (.+)$", text, re.MULTILINE)) + sections, links = {}, {} + for reference in REFERENCE.finditer(text): + name, url = reference.groups() + name = name.lower() + if name in links: + raise ValueError(f"Duplicate comparison reference: {name}") + links[name] = url + for index, heading in enumerate(headings): + match = HEADING.fullmatch(heading[1]) + if not match: + raise ValueError(f"Invalid release heading: {heading[1]}") + name = (match[1] or match[2]).lower() + if name in sections: + raise ValueError(f"Duplicate release heading: {name}") + if not heading[1].startswith("[") and name != "1.0.5": + raise ValueError(f"Release heading must be linked: {name}") + end = headings[index + 1].start() if index + 1 < len(headings) else len(text) + sections[name] = REFERENCE.sub("", text[heading.end() : end]).strip() + if heading[1].startswith("[") and name not in links: + raise ValueError(f"Missing comparison reference: {name}") + if ( + not sections + or next(iter(sections)) != "unreleased" + or "unreleased" not in links + ): + raise ValueError("Changelog must start with a linked Unreleased section") + if sections["unreleased"]: + raise ValueError( + "Unreleased must be empty; release notes are generated from PR titles" + ) + versions = [name for name in sections if name != "unreleased"] + if not versions: + raise ValueError("Changelog has no released versions") + for version in versions: + # Historical versions without a verifiable tag stay unlinked. + if version not in links: + continue + expected = ( + rf"{re.escape(REPOSITORY)}/compare/v{VERSION}\.\.\.v{re.escape(version)}" + ) + if not re.fullmatch(expected, links[version]) and not ( + version == versions[-1] + and links[version] == f"{REPOSITORY}/tree/v{version}" + ): + raise ValueError(f"Invalid comparison URL for {version}: {links[version]}") + if links["unreleased"] != f"{REPOSITORY}/compare/v{versions[0]}...HEAD": + raise ValueError( + "Unreleased comparison must start at the latest v-prefixed tag" + ) + return sections, links, versions[0] + + +def validate(root, next_version=None, tag=None): + sections, links, latest = read_changelog(root) + version = (root / "VERSION").read_text().strip() + manifest = re.search( + r"^version:\s*(\S+)\s*$", (root / "pubspec.yaml").read_text(), re.MULTILINE + ) + if not manifest or manifest[1] != version or latest != version: + raise ValueError( + "VERSION, pubspec.yaml and the latest changelog heading must agree" + ) + if not any( + line.strip() and not line.startswith("#") + for line in sections[latest].splitlines() + ): + raise ValueError(f"Release notes are empty for {latest}") + if tag is not None and tag != f"v{version}": + raise ValueError(f"Tag must match v{version}, got {tag}") + if next_version is not None: + if not re.fullmatch(r"\d+\.\d+\.\d+", next_version) or next_version in sections: + raise ValueError("Next version must be a new bare stable version") + if tuple(map(int, next_version.split("."))) <= tuple( + map(int, version.split(".")) + ): + raise ValueError("Next version must be newer than the current version") + shallow = subprocess.check_output( + ["git", "rev-parse", "--is-shallow-repository"], cwd=root, text=True + ).strip() + if shallow != "false": + raise ValueError("Changelog generation requires fetch-depth: 0") + subprocess.run( + ["git", "merge-base", "--is-ancestor", f"v{version}", "HEAD"], + cwd=root, + check=True, + ) + tags = subprocess.check_output( + ["git", "tag", "--merged", "HEAD"], cwd=root, text=True + ).splitlines() + stable_tags = [ + value for value in tags if re.fullmatch(r"v\d+\.\d+\.\d+", value) + ] + if ( + max(stable_tags, key=lambda value: tuple(map(int, value[1:].split(".")))) + != f"v{version}" + ): + raise ValueError("Latest reachable release tag must match VERSION") + return sections, links, latest + + +def prepare_body(root, tag, notes_file, output): + sections, links, version = validate(root, tag=tag) + notes = notes_file.read_text().strip() + if notes != sections[version]: + raise ValueError( + "Extracted release notes do not match the tagged version's section" + ) + output.write_text( + f"## Release notes:\n{notes}\n\n**Full Changelog**: {links[version]}\n" + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(".")) + commands = parser.add_subparsers(dest="command", required=True) + check = commands.add_parser("check") + check.add_argument("--next-version") + check.add_argument("--tag") + body = commands.add_parser("body") + body.add_argument("--tag", required=True) + body.add_argument("--notes-file", type=Path, required=True) + body.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + try: + if args.command == "check": + validate(args.root, args.next_version, args.tag) + else: + prepare_body(args.root, args.tag, args.notes_file, args.output) + except (ValueError, OSError, subprocess.CalledProcessError) as error: + parser.exit(1, f"Release validation failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test_release.py b/.github/scripts/test_release.py new file mode 100644 index 0000000..0dc2c90 --- /dev/null +++ b/.github/scripts/test_release.py @@ -0,0 +1,134 @@ +"""Regression tests for changelog validation and release body preparation.""" + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +import release + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.text = ( + "# Changelog\n\n## [Unreleased]\n\n## [2.3.0] - 2026-10-27\n\n" + "### Added\n\n- A feature ([#1](https://github.com/example/sdk/pull/1)).\n\n" + "## [2.2.1] - 2026-09-15\n\n- An older fix.\n\n" + f"[unreleased]: {release.REPOSITORY}/compare/v2.3.0...HEAD\n" + f"[2.3.0]: {release.REPOSITORY}/compare/v2.2.1...v2.3.0\n" + f"[2.2.1]: {release.REPOSITORY}/tree/v2.2.1\n" + ) + (self.root / "CHANGELOG.md").write_text(self.text) + (self.root / "VERSION").write_text("2.3.0\n") + (self.root / "pubspec.yaml").write_text("name: example\nversion: 2.3.0\n") + + def test_current_release_body(self): + with (self.root / "CHANGELOG.md").open("a") as changelog: + changelog.write( + f"[12.3.0]: {release.REPOSITORY}/compare/v12.2.0...v12.3.0\n" + ) + sections, links, version = release.validate(self.root, tag="v2.3.0") + notes, output = self.root / "notes.md", self.root / "body.md" + notes.write_text(sections[version] + "\n") + release.prepare_body(self.root, "v2.3.0", notes, output) + self.assertEqual( + output.read_text(), + f"## Release notes:\n{sections[version]}\n\n**Full Changelog**: {links[version]}\n", + ) + notes.write_text(sections["2.2.1"]) + with self.assertRaisesRegex(ValueError, "do not match"): + release.prepare_body(self.root, "v2.3.0", notes, output) + self.assertIn(f"**Full Changelog**: {links[version]}", output.read_text()) + + def test_invalid_changelogs(self): + cases = { + "duplicate heading": ("## [2.2.1] - 2026-09-15", "## [2.3.0] - 2026-09-15"), + "duplicate reference": ("[2.2.1]:", "[2.3.0]:"), + "manual Unreleased": ( + "## [Unreleased]\n", + "## [Unreleased]\n\n- Manual note.\n", + ), + "missing heading": ("## [Unreleased]", "# Unreleased"), + "missing reference": ("[2.3.0]:", "[other]:"), + "missing Unreleased link": ("[unreleased]:", "[other]:"), + "empty release": ( + "- A feature ([#1](https://github.com/example/sdk/pull/1)).", + "", + ), + "bare comparison": ("compare/v2.2.1...v2.3.0", "compare/2.2.1...2.3.0"), + "wrong comparison target": ( + "compare/v2.2.1...v2.3.0", + "compare/v2.2.1...v2.3.1", + ), + "wrong Unreleased base": ("compare/v2.3.0...HEAD", "compare/v2.2.1...HEAD"), + "unlinked current version": ("## [2.3.0]", "## 2.3.0"), + } + for name, (old, new) in cases.items(): + with self.subTest(name=name): + text = self.text.replace(old, new) + (self.root / "CHANGELOG.md").write_text(text) + with self.assertRaises(ValueError): + release.validate(self.root) + self.assertEqual((self.root / "CHANGELOG.md").read_text(), text) + + def test_mismatched_versions_and_tags(self): + for tag in ("2.3.0", "v2.2.1", "v2.3.01"): + with self.subTest(tag=tag), self.assertRaises(ValueError): + release.validate(self.root, tag=tag) + for file in ("VERSION", "pubspec.yaml"): + path = self.root / file + original = path.read_text() + path.write_text(original.replace("2.3.0", "2.3.1")) + with self.subTest(file=file), self.assertRaises(ValueError): + release.validate(self.root) + path.write_text(original) + + def test_next_version_and_history(self): + self.git("init", "-q") + self.git( + "-c", + "user.name=Example", + "-c", + "user.email=example@example.com", + "commit", + "--allow-empty", + "-qm", + "Initial", + ) + self.git("tag", "v2.3.0") + release.validate(self.root, next_version="2.3.1") + for version in ("2.3.0", "2.2.0", "v2.3.1"): + with self.subTest(version=version), self.assertRaises(ValueError): + release.validate(self.root, next_version=version) + self.git("tag", "v2.4.0") + with self.assertRaisesRegex(ValueError, "Latest reachable"): + release.validate(self.root, next_version="2.3.1") + self.git("tag", "-d", "v2.4.0") + (self.root / ".git" / "shallow").write_text( + self.git("rev-parse", "HEAD").strip() + "\n" + ) + with self.assertRaisesRegex(ValueError, "fetch-depth"): + release.validate(self.root, next_version="2.3.1") + + def test_generation_noop_fails_post_validation(self): + for file in ("VERSION", "pubspec.yaml"): + path = self.root / file + path.write_text(path.read_text().replace("2.3.0", "2.3.1")) + with self.assertRaisesRegex(ValueError, "must agree"): + release.validate(self.root, tag="v2.3.1") + + def git(self, *args): + return subprocess.check_output( + ["git", "-c", "core.hooksPath=/dev/null", *args], + cwd=self.root, + text=True, + env={**os.environ, "GIT_CONFIG_NOSYSTEM": "1"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 6c204ad..a0e8716 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -38,7 +38,7 @@ jobs: - build-ios - build-web name: Notify GChat - uses: ROKT/rokt-workflows/.github/workflows/oss_pr_opened_notification.yml@88d0e118869cc788aae18ba37635fa851c879b73 # main + uses: ROKT/rokt-workflows/.github/workflows/oss_pr_opened_notification.yml@490d3ec9069eeedb757be147bf440d6394705e84 # main permissions: contents: read id-token: write diff --git a/.github/workflows/release-draft.yml b/.github/workflows/release-draft.yml index bb26ae9..b150997 100644 --- a/.github/workflows/release-draft.yml +++ b/.github/workflows/release-draft.yml @@ -22,9 +22,13 @@ permissions: jobs: publish-draft-release: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false - name: Get current version id: version-file @@ -39,18 +43,29 @@ jobs: current_version: ${{ steps.version-file.outputs.release-version }} level: ${{ github.event.inputs.bump-type || 'patch' }} - - name: Save validated version to file + - name: Validate changelog before generation + env: + NEW_VERSION: ${{ steps.bump-version.outputs.new_version }} run: | - echo "${{ steps.bump-version.outputs.new_version }}" > VERSION + python3 .github/scripts/release.py check --next-version "$NEW_VERSION" - - name: Update changelog - uses: thomaseizinger/keep-a-changelog-new-release@f62c3c390716df5af712ba5d94f4f4a8efc1306d # v3 + - name: Generate changelog from merged PR titles + id: changelog + uses: ROKT/rokt-workflows/actions/generate-changelog@da0c5fd5d38e9cf113a24ec9aaea079cc7d0bc40 # main + env: + GH_TOKEN: ${{ github.token }} with: - tag: ${{ steps.bump-version.outputs.new_version }} + version: ${{ steps.bump-version.outputs.new_version }} + tag-prefix: v + changelog-path: CHANGELOG.md - - name: Update pubspec.yaml + - name: Update and validate release versions + env: + NEW_VERSION: ${{ steps.bump-version.outputs.new_version }} run: | - sed -i 's/version: .*/version: ${{ steps.bump-version.outputs.new_version }}/' pubspec.yaml + printf '%s\n' "$NEW_VERSION" > VERSION + sed -i "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml + python3 .github/scripts/release.py check --tag "v${NEW_VERSION}" - name: Setup Flutter uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 @@ -84,5 +99,8 @@ jobs: - Updated CHANGELOG.md - Updated VERSION file - Ran a dry-run publish + + ## Changelog + ${{ steps.changelog.outputs.release-notes }} labels: | release diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 649bd7e..4f624fc 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -46,20 +46,32 @@ jobs: channel: stable cache: true - - name: Publish Package - run: | - flutter pub publish --force + - name: Validate tagged release + env: + TAG_NAME: ${{ github.ref_name }} + run: python3 .github/scripts/release.py check --tag "$TAG_NAME" - uses: ffurrer2/extract-release-notes@273da39a24fb7db106a35526c8162815faffd31d # v3.1.0 id: extract-release-notes with: changelog_file: CHANGELOG.md + release_notes_file: ${{ runner.temp }}/release-notes.md + + - name: Prepare GitHub release body + env: + TAG_NAME: ${{ github.ref_name }} + NOTES_FILE: ${{ runner.temp }}/release-notes.md + BODY_FILE: ${{ runner.temp }}/release-body.md + run: | + python3 .github/scripts/release.py body --tag "$TAG_NAME" --notes-file "$NOTES_FILE" --output "$BODY_FILE" + + - name: Publish Package + run: | + flutter pub publish --force - name: Create Github release uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1 with: makeLatest: true tag: ${{ github.ref_name }} - body: | - ## Release notes: - ${{ steps.extract-release-notes.outputs.release_notes }} + bodyFile: ${{ runner.temp }}/release-body.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee48481..8aed768 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Validate release metadata and automation + run: | + python3 .github/scripts/release.py check + python3 -m unittest discover -s .github/scripts -p 'test_*.py' - name: Setup Flutter SDK uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 252c377..e9df746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,18 +28,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.2.0] - 2026-07-27 -### Changed - -- Make `android-rokt-kit` a `compileOnly` plugin dependency and guard Android Rokt registration behind a runtime classpath check, matching the optional Rokt kit pattern used in react-native-mparticle. - -## [2.2.0] - 2026-07-22 - ### Added - Add Swift Package Manager support for the iOS plugin (`ios/mparticle_flutter_sdk/Package.swift`). ### Changed +- Make `android-rokt-kit` a `compileOnly` plugin dependency and guard Android Rokt registration behind a runtime classpath check, matching the optional Rokt kit pattern used in react-native-mparticle. - Bump iOS CocoaPods dependency from `mParticle-Apple-SDK ~> 9.2` to `~> 9.3`. - Bump Android Gradle dependencies to `android-core:6.0.0` and `android-rokt-kit:6.0.0`. - **BREAKING**: Android `minSdkVersion` raised from `16` to `23` (required by Rokt SDK 6.x transitive dependencies). @@ -102,7 +97,7 @@ See [MIGRATING.md](./MIGRATING.md) for the full 1.x → 2.0 upgrade guide. - fix: Resolve linting issues (#48) -## [1.0.5] - 2024-07-10 +## 1.0.5 - 2024-07-10 - fix: type 'Null' is not a subtype of type 'String' in getCurrentUser (#46) @@ -144,10 +139,21 @@ See [MIGRATING.md](./MIGRATING.md) for the full 1.x → 2.0 upgrade guide. - Identity API (identify, login, logout, and alias) - Github Actions - semantic PR title check; plugin building -[unreleased]: https://github.com/mParticle/mparticle-flutter-sdk/compare/2.3.0...HEAD -[2.3.0]: https://github.com/mParticle/mparticle-flutter-sdk/compare/2.2.1...2.3.0 -[2.2.1]: https://github.com/mParticle/mparticle-flutter-sdk/compare/2.2.0...2.2.1 -[2.2.0]: https://github.com/mParticle/mparticle-flutter-sdk/compare/2.1.0...2.2.0 -[2.1.0]: https://github.com/mParticle/mparticle-flutter-sdk/compare/2.0.0...2.1.0 -[2.0.0]: https://github.com/mParticle/mparticle-flutter-sdk/compare/1.1.2...2.0.0 -[1.1.2]: https://github.com/mParticle/mparticle-flutter-sdk/compare/47676b23d065e77f8ef7c9e2938793a93ec6dcc6...1.1.2 +[unreleased]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v2.3.0...HEAD +[2.3.0]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v2.2.1...v2.3.0 +[2.2.1]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v2.2.0...v2.2.1 +[2.2.0]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v2.1.0...v2.2.0 +[2.1.0]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v2.0.0...v2.1.0 +[2.0.0]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v1.1.2...v2.0.0 +[1.1.2]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v1.1.1...v1.1.2 +[1.1.1]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v1.1.0...v1.1.1 +[1.1.0]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v1.0.6...v1.1.0 +[1.0.6]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v1.0.4...v1.0.6 +[1.0.4]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v1.0.3...v1.0.4 +[1.0.3]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v1.0.2...v1.0.3 +[1.0.2]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v1.0.0-beta.1...v1.0.1 +[1.0.0-beta.1]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v0.2.0-alpha.1...v1.0.0-beta.1 +[0.2.0-alpha.1]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v0.1.0-alpha.2...v0.2.0-alpha.1 +[0.1.0-alpha.2]: https://github.com/mParticle/mparticle-flutter-sdk/compare/v0.1.0-alpha.1...v0.1.0-alpha.2 +[0.1.0-alpha.1]: https://github.com/mParticle/mparticle-flutter-sdk/tree/v0.1.0-alpha.1 diff --git a/MIGRATING.md b/MIGRATING.md index b2424c5..b0eecd4 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -14,7 +14,7 @@ CocoaPods trunk becomes read-only on 2 December 2026, and Flutter uses Swift Pac 2. Remove every mParticle and Rokt pod from `ios/Podfile`, for example `mParticle-Apple-SDK`, `mParticle-Rokt`, `RoktPaymentExtension` and `RoktSDKPlus`. - If nothing else is left in the Podfile, run `pod deintegrate` in `ios/`, delete `Podfile` and `Podfile.lock`, and remove the `Pods-Runner` includes from `ios/Flutter/Debug.xcconfig` and `ios/Flutter/Release.xcconfig`. - Otherwise, run `pod install`. -3. Add the kits as Swift packages to the Runner target, as described in the iOS section of the [README](README.md#iOS). +3. Add the kits as Swift packages to the Runner target, as described in the iOS section of the [README](README.md#ios). 4. Run a Debug build once and check the console for `[mParticle] The mParticle SDK is loaded more than once`. If it appears, a pod still brings in the SDK. 5. Commit the Xcode project change and `ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved`. diff --git a/README.md b/README.md index 0572254..a088d8f 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,7 @@ For more help, see the [full Web set up docs](https://docs.mparticle.com/develop ## Usage -Each of our Dart methods is mapped to an underlying mParticle SDK at the platform level. Note that per Dart's [documentation](https://flutter.dev/docs/development/platform-integration/platform-channels#architecture, calling into platform specific code is asynchronous to ensure the user interface remains responsive. In your code, you can swap usage between `async` and `then` in accordance to your app's requirements. +Each of our Dart methods is mapped to an underlying mParticle SDK at the platform level. Note that per Dart's [documentation](https://flutter.dev/docs/development/platform-integration/platform-channels#architecture), calling into platform specific code is asynchronous to ensure the user interface remains responsive. In your code, you can swap usage between `async` and `then` in accordance to your app's requirements. For a full description of all classes, methods, and properties, see the [mParticle Flutter SDK API Reference](https://pub.dev/documentation/mparticle_flutter_sdk/latest/). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..bf22b6a --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,36 @@ +# Releasing + +1. Run **Release – Draft** on `main` and choose a patch, minor, or major bump. + The workflow uses the shared Rokt changelog generator to collect merged PR + titles since the previous release tag. All commit types are included, including + dependency updates titled `chore`. The generated notes appear in both the + changelog and the release PR description. +2. Review the generated release PR. Check dependency changes, migration guidance, + breaking changes, version files, and comparison links before merging it. + Conventional PR titles determine categories; use `!` for breaking changes. + Keep **Unreleased empty** rather than adding handwritten changelog entries. + Detailed guidance can be added to the generated section during release review. +3. Merging the release PR updates `VERSION` and creates its `v`-prefixed tag. + **Release – Publish** runs tests and builds, validates the tagged version, + extracts its notes, and prepares the GitHub release body before publishing. + The release body includes the exact version's **Full Changelog** URL. + +Package versions and changelog headings use bare versions (`2.3.0`), while Git +tags and comparison endpoints use `v` (`v2.3.0`). Changelog generation requires a +full checkout with tags (`fetch-depth: 0`) and explicitly sets `tag-prefix: v`. +Historical headings link existing tags; `1.0.5` stays unlinked because no tag +exists, and the earliest release links its tagged source tree. + +Run the local release checks with: + +```sh +python3 .github/scripts/release.py check +python3 -m unittest discover -s .github/scripts -p 'test_*.py' +flutter test +``` + +To repair an existing GitHub release, first save its body, prepare the corrected +notes and matching comparison footer, and apply them with +`gh release edit TAG --notes-file FILE`. Re-fetch the release to verify the edit. +Keep its tag, assets and publication metadata unchanged. Published package +archives receive documentation repairs only in a subsequent package version. From 7178a0109cb8510f8dfb436465bc7898a2720e9f Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Tue, 27 Oct 2026 14:35:56 -0400 Subject: [PATCH 2/2] refactor(releases): keep release workflow changes minimal --- .github/scripts/release.py | 154 -------------------------- .github/scripts/test_release.py | 134 ---------------------- .github/workflows/release-draft.yml | 9 +- .github/workflows/release-publish.yml | 13 +-- .github/workflows/test.yml | 4 - RELEASING.md | 46 ++------ 6 files changed, 18 insertions(+), 342 deletions(-) delete mode 100644 .github/scripts/release.py delete mode 100644 .github/scripts/test_release.py diff --git a/.github/scripts/release.py b/.github/scripts/release.py deleted file mode 100644 index fa58fd9..0000000 --- a/.github/scripts/release.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Validate release metadata and prepare the GitHub release description.""" - -import argparse -import re -import subprocess -from pathlib import Path - -REPOSITORY = "https://github.com/mParticle/mparticle-flutter-sdk" -VERSION = r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?" -HEADING = re.compile( - rf"(?:\[(Unreleased|{VERSION})\]|({VERSION}))(?: - \d{{4}}-\d{{2}}-\d{{2}})?", - re.IGNORECASE, -) -REFERENCE = re.compile(r"^\[([^\]]+)\]:\s+(\S+)\s*$", re.MULTILINE) - - -def read_changelog(root): - text = (root / "CHANGELOG.md").read_text() - headings = list(re.finditer(r"^## (.+)$", text, re.MULTILINE)) - sections, links = {}, {} - for reference in REFERENCE.finditer(text): - name, url = reference.groups() - name = name.lower() - if name in links: - raise ValueError(f"Duplicate comparison reference: {name}") - links[name] = url - for index, heading in enumerate(headings): - match = HEADING.fullmatch(heading[1]) - if not match: - raise ValueError(f"Invalid release heading: {heading[1]}") - name = (match[1] or match[2]).lower() - if name in sections: - raise ValueError(f"Duplicate release heading: {name}") - if not heading[1].startswith("[") and name != "1.0.5": - raise ValueError(f"Release heading must be linked: {name}") - end = headings[index + 1].start() if index + 1 < len(headings) else len(text) - sections[name] = REFERENCE.sub("", text[heading.end() : end]).strip() - if heading[1].startswith("[") and name not in links: - raise ValueError(f"Missing comparison reference: {name}") - if ( - not sections - or next(iter(sections)) != "unreleased" - or "unreleased" not in links - ): - raise ValueError("Changelog must start with a linked Unreleased section") - if sections["unreleased"]: - raise ValueError( - "Unreleased must be empty; release notes are generated from PR titles" - ) - versions = [name for name in sections if name != "unreleased"] - if not versions: - raise ValueError("Changelog has no released versions") - for version in versions: - # Historical versions without a verifiable tag stay unlinked. - if version not in links: - continue - expected = ( - rf"{re.escape(REPOSITORY)}/compare/v{VERSION}\.\.\.v{re.escape(version)}" - ) - if not re.fullmatch(expected, links[version]) and not ( - version == versions[-1] - and links[version] == f"{REPOSITORY}/tree/v{version}" - ): - raise ValueError(f"Invalid comparison URL for {version}: {links[version]}") - if links["unreleased"] != f"{REPOSITORY}/compare/v{versions[0]}...HEAD": - raise ValueError( - "Unreleased comparison must start at the latest v-prefixed tag" - ) - return sections, links, versions[0] - - -def validate(root, next_version=None, tag=None): - sections, links, latest = read_changelog(root) - version = (root / "VERSION").read_text().strip() - manifest = re.search( - r"^version:\s*(\S+)\s*$", (root / "pubspec.yaml").read_text(), re.MULTILINE - ) - if not manifest or manifest[1] != version or latest != version: - raise ValueError( - "VERSION, pubspec.yaml and the latest changelog heading must agree" - ) - if not any( - line.strip() and not line.startswith("#") - for line in sections[latest].splitlines() - ): - raise ValueError(f"Release notes are empty for {latest}") - if tag is not None and tag != f"v{version}": - raise ValueError(f"Tag must match v{version}, got {tag}") - if next_version is not None: - if not re.fullmatch(r"\d+\.\d+\.\d+", next_version) or next_version in sections: - raise ValueError("Next version must be a new bare stable version") - if tuple(map(int, next_version.split("."))) <= tuple( - map(int, version.split(".")) - ): - raise ValueError("Next version must be newer than the current version") - shallow = subprocess.check_output( - ["git", "rev-parse", "--is-shallow-repository"], cwd=root, text=True - ).strip() - if shallow != "false": - raise ValueError("Changelog generation requires fetch-depth: 0") - subprocess.run( - ["git", "merge-base", "--is-ancestor", f"v{version}", "HEAD"], - cwd=root, - check=True, - ) - tags = subprocess.check_output( - ["git", "tag", "--merged", "HEAD"], cwd=root, text=True - ).splitlines() - stable_tags = [ - value for value in tags if re.fullmatch(r"v\d+\.\d+\.\d+", value) - ] - if ( - max(stable_tags, key=lambda value: tuple(map(int, value[1:].split(".")))) - != f"v{version}" - ): - raise ValueError("Latest reachable release tag must match VERSION") - return sections, links, latest - - -def prepare_body(root, tag, notes_file, output): - sections, links, version = validate(root, tag=tag) - notes = notes_file.read_text().strip() - if notes != sections[version]: - raise ValueError( - "Extracted release notes do not match the tagged version's section" - ) - output.write_text( - f"## Release notes:\n{notes}\n\n**Full Changelog**: {links[version]}\n" - ) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--root", type=Path, default=Path(".")) - commands = parser.add_subparsers(dest="command", required=True) - check = commands.add_parser("check") - check.add_argument("--next-version") - check.add_argument("--tag") - body = commands.add_parser("body") - body.add_argument("--tag", required=True) - body.add_argument("--notes-file", type=Path, required=True) - body.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - try: - if args.command == "check": - validate(args.root, args.next_version, args.tag) - else: - prepare_body(args.root, args.tag, args.notes_file, args.output) - except (ValueError, OSError, subprocess.CalledProcessError) as error: - parser.exit(1, f"Release validation failed: {error}\n") - - -if __name__ == "__main__": - main() diff --git a/.github/scripts/test_release.py b/.github/scripts/test_release.py deleted file mode 100644 index 0dc2c90..0000000 --- a/.github/scripts/test_release.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Regression tests for changelog validation and release body preparation.""" - -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - -import release - - -class ReleaseTests(unittest.TestCase): - def setUp(self): - self.directory = tempfile.TemporaryDirectory() - self.addCleanup(self.directory.cleanup) - self.root = Path(self.directory.name) - self.text = ( - "# Changelog\n\n## [Unreleased]\n\n## [2.3.0] - 2026-10-27\n\n" - "### Added\n\n- A feature ([#1](https://github.com/example/sdk/pull/1)).\n\n" - "## [2.2.1] - 2026-09-15\n\n- An older fix.\n\n" - f"[unreleased]: {release.REPOSITORY}/compare/v2.3.0...HEAD\n" - f"[2.3.0]: {release.REPOSITORY}/compare/v2.2.1...v2.3.0\n" - f"[2.2.1]: {release.REPOSITORY}/tree/v2.2.1\n" - ) - (self.root / "CHANGELOG.md").write_text(self.text) - (self.root / "VERSION").write_text("2.3.0\n") - (self.root / "pubspec.yaml").write_text("name: example\nversion: 2.3.0\n") - - def test_current_release_body(self): - with (self.root / "CHANGELOG.md").open("a") as changelog: - changelog.write( - f"[12.3.0]: {release.REPOSITORY}/compare/v12.2.0...v12.3.0\n" - ) - sections, links, version = release.validate(self.root, tag="v2.3.0") - notes, output = self.root / "notes.md", self.root / "body.md" - notes.write_text(sections[version] + "\n") - release.prepare_body(self.root, "v2.3.0", notes, output) - self.assertEqual( - output.read_text(), - f"## Release notes:\n{sections[version]}\n\n**Full Changelog**: {links[version]}\n", - ) - notes.write_text(sections["2.2.1"]) - with self.assertRaisesRegex(ValueError, "do not match"): - release.prepare_body(self.root, "v2.3.0", notes, output) - self.assertIn(f"**Full Changelog**: {links[version]}", output.read_text()) - - def test_invalid_changelogs(self): - cases = { - "duplicate heading": ("## [2.2.1] - 2026-09-15", "## [2.3.0] - 2026-09-15"), - "duplicate reference": ("[2.2.1]:", "[2.3.0]:"), - "manual Unreleased": ( - "## [Unreleased]\n", - "## [Unreleased]\n\n- Manual note.\n", - ), - "missing heading": ("## [Unreleased]", "# Unreleased"), - "missing reference": ("[2.3.0]:", "[other]:"), - "missing Unreleased link": ("[unreleased]:", "[other]:"), - "empty release": ( - "- A feature ([#1](https://github.com/example/sdk/pull/1)).", - "", - ), - "bare comparison": ("compare/v2.2.1...v2.3.0", "compare/2.2.1...2.3.0"), - "wrong comparison target": ( - "compare/v2.2.1...v2.3.0", - "compare/v2.2.1...v2.3.1", - ), - "wrong Unreleased base": ("compare/v2.3.0...HEAD", "compare/v2.2.1...HEAD"), - "unlinked current version": ("## [2.3.0]", "## 2.3.0"), - } - for name, (old, new) in cases.items(): - with self.subTest(name=name): - text = self.text.replace(old, new) - (self.root / "CHANGELOG.md").write_text(text) - with self.assertRaises(ValueError): - release.validate(self.root) - self.assertEqual((self.root / "CHANGELOG.md").read_text(), text) - - def test_mismatched_versions_and_tags(self): - for tag in ("2.3.0", "v2.2.1", "v2.3.01"): - with self.subTest(tag=tag), self.assertRaises(ValueError): - release.validate(self.root, tag=tag) - for file in ("VERSION", "pubspec.yaml"): - path = self.root / file - original = path.read_text() - path.write_text(original.replace("2.3.0", "2.3.1")) - with self.subTest(file=file), self.assertRaises(ValueError): - release.validate(self.root) - path.write_text(original) - - def test_next_version_and_history(self): - self.git("init", "-q") - self.git( - "-c", - "user.name=Example", - "-c", - "user.email=example@example.com", - "commit", - "--allow-empty", - "-qm", - "Initial", - ) - self.git("tag", "v2.3.0") - release.validate(self.root, next_version="2.3.1") - for version in ("2.3.0", "2.2.0", "v2.3.1"): - with self.subTest(version=version), self.assertRaises(ValueError): - release.validate(self.root, next_version=version) - self.git("tag", "v2.4.0") - with self.assertRaisesRegex(ValueError, "Latest reachable"): - release.validate(self.root, next_version="2.3.1") - self.git("tag", "-d", "v2.4.0") - (self.root / ".git" / "shallow").write_text( - self.git("rev-parse", "HEAD").strip() + "\n" - ) - with self.assertRaisesRegex(ValueError, "fetch-depth"): - release.validate(self.root, next_version="2.3.1") - - def test_generation_noop_fails_post_validation(self): - for file in ("VERSION", "pubspec.yaml"): - path = self.root / file - path.write_text(path.read_text().replace("2.3.0", "2.3.1")) - with self.assertRaisesRegex(ValueError, "must agree"): - release.validate(self.root, tag="v2.3.1") - - def git(self, *args): - return subprocess.check_output( - ["git", "-c", "core.hooksPath=/dev/null", *args], - cwd=self.root, - text=True, - env={**os.environ, "GIT_CONFIG_NOSYSTEM": "1"}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/.github/workflows/release-draft.yml b/.github/workflows/release-draft.yml index b150997..6d15b0b 100644 --- a/.github/workflows/release-draft.yml +++ b/.github/workflows/release-draft.yml @@ -43,12 +43,6 @@ jobs: current_version: ${{ steps.version-file.outputs.release-version }} level: ${{ github.event.inputs.bump-type || 'patch' }} - - name: Validate changelog before generation - env: - NEW_VERSION: ${{ steps.bump-version.outputs.new_version }} - run: | - python3 .github/scripts/release.py check --next-version "$NEW_VERSION" - - name: Generate changelog from merged PR titles id: changelog uses: ROKT/rokt-workflows/actions/generate-changelog@da0c5fd5d38e9cf113a24ec9aaea079cc7d0bc40 # main @@ -59,13 +53,12 @@ jobs: tag-prefix: v changelog-path: CHANGELOG.md - - name: Update and validate release versions + - name: Update release versions env: NEW_VERSION: ${{ steps.bump-version.outputs.new_version }} run: | printf '%s\n' "$NEW_VERSION" > VERSION sed -i "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml - python3 .github/scripts/release.py check --tag "v${NEW_VERSION}" - name: Setup Flutter uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 4f624fc..b08003f 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -46,24 +46,21 @@ jobs: channel: stable cache: true - - name: Validate tagged release - env: - TAG_NAME: ${{ github.ref_name }} - run: python3 .github/scripts/release.py check --tag "$TAG_NAME" - - uses: ffurrer2/extract-release-notes@273da39a24fb7db106a35526c8162815faffd31d # v3.1.0 id: extract-release-notes with: changelog_file: CHANGELOG.md - release_notes_file: ${{ runner.temp }}/release-notes.md - name: Prepare GitHub release body env: TAG_NAME: ${{ github.ref_name }} - NOTES_FILE: ${{ runner.temp }}/release-notes.md + RELEASE_NOTES: ${{ steps.extract-release-notes.outputs.release_notes }} BODY_FILE: ${{ runner.temp }}/release-body.md run: | - python3 .github/scripts/release.py body --tag "$TAG_NAME" --notes-file "$NOTES_FILE" --output "$BODY_FILE" + version="${TAG_NAME#v}" + test "$(head -n 1 VERSION)" = "$version" + comparison_url=$(awk -v reference="[${version}]:" '$1 == reference { matches++; url=$2 } END { if (matches != 1 || url == "") exit 1; print url }' CHANGELOG.md) + printf '## Release notes:\n%s\n\n**Full Changelog**: %s\n' "$RELEASE_NOTES" "$comparison_url" > "$BODY_FILE" - name: Publish Package run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8aed768..ee48481 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Validate release metadata and automation - run: | - python3 .github/scripts/release.py check - python3 -m unittest discover -s .github/scripts -p 'test_*.py' - name: Setup Flutter SDK uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: diff --git a/RELEASING.md b/RELEASING.md index bf22b6a..86a6e8d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,36 +1,14 @@ # Releasing -1. Run **Release – Draft** on `main` and choose a patch, minor, or major bump. - The workflow uses the shared Rokt changelog generator to collect merged PR - titles since the previous release tag. All commit types are included, including - dependency updates titled `chore`. The generated notes appear in both the - changelog and the release PR description. -2. Review the generated release PR. Check dependency changes, migration guidance, - breaking changes, version files, and comparison links before merging it. - Conventional PR titles determine categories; use `!` for breaking changes. - Keep **Unreleased empty** rather than adding handwritten changelog entries. - Detailed guidance can be added to the generated section during release review. -3. Merging the release PR updates `VERSION` and creates its `v`-prefixed tag. - **Release – Publish** runs tests and builds, validates the tagged version, - extracts its notes, and prepares the GitHub release body before publishing. - The release body includes the exact version's **Full Changelog** URL. - -Package versions and changelog headings use bare versions (`2.3.0`), while Git -tags and comparison endpoints use `v` (`v2.3.0`). Changelog generation requires a -full checkout with tags (`fetch-depth: 0`) and explicitly sets `tag-prefix: v`. -Historical headings link existing tags; `1.0.5` stays unlinked because no tag -exists, and the earliest release links its tagged source tree. - -Run the local release checks with: - -```sh -python3 .github/scripts/release.py check -python3 -m unittest discover -s .github/scripts -p 'test_*.py' -flutter test -``` - -To repair an existing GitHub release, first save its body, prepare the corrected -notes and matching comparison footer, and apply them with -`gh release edit TAG --notes-file FILE`. Re-fetch the release to verify the edit. -Keep its tag, assets and publication metadata unchanged. Published package -archives receive documentation repairs only in a subsequent package version. +Run **Release – Draft** on `main` and choose a patch, minor, or major bump. +The shared Rokt action generates changelog entries from merged PR titles since +the previous release tag, including all commit types. Conventional prefixes +determine categories; use `!` for breaking changes. Keep Unreleased empty. + +Review the generated notes in the release PR, adding migration guidance where +needed. Merging creates the `v`-prefixed tag and starts tests, builds and +publication. The GitHub release includes the matching Full Changelog URL. + +Package versions and changelog headings use bare versions (`2.3.0`); tags use +`v` (`v2.3.0`). Generation uses full history and explicitly sets `tag-prefix: v`. +Historical links retain existing tags; the untagged `1.0.5` heading stays unlinked.