diff --git a/.github/scripts/check-release-metadata.sh b/.github/scripts/check-release-metadata.sh new file mode 100755 index 0000000..6e5221e --- /dev/null +++ b/.github/scripts/check-release-metadata.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Resolves which package a publish run targets and fails the run, before anyone +# is asked to approve it, if that package is inconsistent: pubspec, changelog, +# version.dart and the tag must all agree on the version. +# +# Inputs (env): EVENT github.event_name (push of a tag, or workflow_dispatch) +# TAG github.ref_name, read only for push +# PACKAGE workflow_dispatch input: path under packages/ +# GITHUB_OUTPUT receives `package=` +set -euo pipefail + +fail() { echo "::error::$*"; exit 1; } + +if [[ "$EVENT" == "push" ]]; then + # pub.dev only accepts tag pushes for push-triggered publishing, and the tag + # is the sole selector of the package: -v. + name="${TAG%%-v[0-9]*}" + case "$name" in + segment_analytics) PACKAGE=core ;; + segment_analytics_plugin_[a-z_]*) PACKAGE="plugins/plugin_${name#segment_analytics_plugin_}" ;; + *) fail "tag '$TAG' does not name a package in this repo" ;; + esac +fi + +# PACKAGE reaches working-directory in the workflow; reject anything that could +# escape packages/. +[[ "${PACKAGE:-}" =~ ^(core|plugins/plugin_[a-z_]+)$ ]] || fail "invalid package '${PACKAGE:-}'" +dir="packages/$PACKAGE" +[[ -f "$dir/pubspec.yaml" ]] || fail "$dir/pubspec.yaml not found" + +name=$(sed -n 's/^name: *//p' "$dir/pubspec.yaml" | head -1) +version=$(sed -n 's/^version: *//p' "$dir/pubspec.yaml" | head -1) +[[ -n "$name" && -n "$version" ]] || fail "could not read name/version from $dir/pubspec.yaml" + +# pub.dev publishes whatever pubspec.yaml says, so the tag must say the same. +if [[ "$EVENT" == "push" ]]; then + [[ "$TAG" == "$name-v$version" ]] || fail "tag '$TAG' != '$name-v$version' (pubspec.yaml in $dir)" +fi + +# Whatever triggered the run, the code being published must be on main. +git fetch -q origin main +git merge-base --is-ancestor "$GITHUB_SHA" origin/main || fail "$GITHUB_SHA is not on main" + +top=$(sed -n 's/^## *//p' "$dir/CHANGELOG.md" | head -1) +[[ "$top" == "$version" ]] || fail "newest CHANGELOG section is '$top', pubspec version is '$version'" + +if [[ "$PACKAGE" == "core" ]]; then + # segmentVersion is sent as context.library.version on every event. + dart_version=$(sed -n 's/^const segmentVersion = "\(.*\)";/\1/p' "$dir/lib/version.dart") + [[ "$dart_version" == "$version" ]] || fail "lib/version.dart is '$dart_version', pubspec version is '$version'" +fi + +echo "OK: $name $version" +echo "package=$PACKAGE" >> "${GITHUB_OUTPUT:-/dev/null}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6d5b717..1a32cc1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,12 +1,18 @@ name: Publish to pub.dev +# pub.dev automated publishing accepts only tag pushes and workflow_dispatch; a +# `release` event is rejected. The tag selects the package (see +# .github/scripts/check-release-metadata.sh). workflow_dispatch is for re-running +# a publish whose tag-triggered run failed. on: - release: - types: [published] + push: + tags: + - 'segment_analytics-v[0-9]+.[0-9]+.[0-9]+*' + - 'segment_analytics_plugin_*-v[0-9]+.[0-9]+.[0-9]+*' workflow_dispatch: inputs: package: - description: "Package to publish (e.g. core, plugins/plugin_firebase)" + description: "Package to publish (core, or plugins/plugin_)" required: true default: "core" @@ -20,9 +26,29 @@ env: FLUTTER_VERSION: "3.38.0" jobs: + meta: + name: Resolve package and check release metadata + runs-on: ubuntu-latest-large + outputs: + package: ${{ steps.check.outputs.package }} + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + + - name: Check release metadata + id: check + run: .github/scripts/check-release-metadata.sh + env: + EVENT: ${{ github.event_name }} + TAG: ${{ github.ref_name }} + PACKAGE: ${{ github.event.inputs.package }} + test: name: Pre-publish tests runs-on: ubuntu-latest-large + needs: [meta] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -43,16 +69,17 @@ jobs: - name: Get dependencies run: flutter pub get - working-directory: packages/${{ github.event.inputs.package || 'core' }} + working-directory: packages/${{ needs.meta.outputs.package }} - name: Run tests - run: flutter test - working-directory: packages/${{ github.event.inputs.package || 'core' }} + run: | + if [ -d test ]; then flutter test; else echo "no test/ directory, skipping"; fi + working-directory: packages/${{ needs.meta.outputs.package }} publish: name: Publish to pub.dev runs-on: ubuntu-latest-large - needs: [test] + needs: [meta, test] environment: production steps: @@ -66,4 +93,4 @@ jobs: - name: Publish to pub.dev (Trusted Publishing) run: dart pub publish --force - working-directory: packages/${{ github.event.inputs.package || 'core' }} + working-directory: packages/${{ needs.meta.outputs.package }} diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..b7e3ad7 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,87 @@ +# Releasing + +This repository is a monorepo of independently versioned packages published to [pub.dev](https://pub.dev). Pushing a package-qualified tag publishes that package from CI (`.github/workflows/publish.yml`) using pub.dev [automated publishing](https://dart.dev/tools/pub/automated-publishing) (OIDC, no stored credentials). + +## Packages + +| Package | Path | +|---------|------| +| `segment_analytics` (core) | `packages/core` | +| `segment_analytics_plugin_adjust` | `packages/plugins/plugin_adjust` | +| `segment_analytics_plugin_advertising_id` | `packages/plugins/plugin_advertising_id` | +| `segment_analytics_plugin_amplitude` | `packages/plugins/plugin_amplitude` | +| `segment_analytics_plugin_appsflyer` | `packages/plugins/plugin_appsflyer` | +| `segment_analytics_plugin_firebase` | `packages/plugins/plugin_firebase` | +| `segment_analytics_plugin_idfa` | `packages/plugins/plugin_idfa` | + +Plugins depend on core through a pub.dev constraint (`segment_analytics: ^1.x`), not a path dependency, so a plugin can only be published against a core version that is already on pub.dev. **Release core first.** + +## Versioning + +[SemVer](https://semver.org/) per package; commits follow [Conventional Commits](CONTRIBUTING.md#commit-message-convention). Versions are bumped by hand in the release PR. + +- A raised minimum Flutter or Dart SDK constraint is a breaking change for every consumer, including Android and web, because pub resolves it for the whole package. Bump the major version. +- Core's version lives in two files that must match, or events report the wrong `context.library.version`: + - `packages/core/pubspec.yaml` → `version:` + - `packages/core/lib/version.dart` → `segmentVersion` +- A plugin's version lives only in its own `pubspec.yaml`. +- When core ships a new major, every plugin's `segment_analytics:` constraint stops matching it. Widen the constraint (e.g. `">=1.1.1 <3.0.0"`) and release the plugins after core. + +## Release steps + +1. **Release PR.** Bump the version(s), add a `## X.Y.Z` section at the top of each package's `CHANGELOG.md`, and update the README if compatibility changes. Merge to `main` with CI green. +2. **Dry run** from the package directory: + + ```bash + cd packages/core # or the plugin being released + flutter pub get + flutter analyze + flutter test + dart pub publish --dry-run + ``` + +3. **Tag and push** from `main` at the merged release commit: + + ```bash + git tag segment_analytics-vX.Y.Z + git push origin segment_analytics-vX.Y.Z + ``` + + The tag is `-v`, e.g. `segment_analytics_plugin_firebase-v1.0.2` for a plugin. The tag alone selects the package. +4. **Approve** the `publish` job when it pauses on the `production` environment. Before that, the workflow checks that the tag, `pubspec.yaml`, the newest `CHANGELOG.md` section and (for core) `lib/version.dart` agree, that the commit is on `main`, and that the package's tests pass. A failure there publishes nothing. +5. **Verify** the version appears on the package's pub.dev page and that a fresh `flutter pub add ` resolves it. + +When releasing several packages, wait for core to appear on pub.dev before tagging a plugin that needs it. + +## Re-running a failed publish + +If the tag-triggered run failed for a transient reason, re-run it from the Actions tab. If the tag points at the wrong commit, fix `main`, then delete and recreate the tag. + +The **Publish to pub.dev** workflow can also be started by hand (`workflow_dispatch`) with `package` set to `core` or `plugins/plugin_`. It runs the same checks. Do not tag afterwards: the tag would start a second publish of a version that already exists, which fails. + +## One-time setup + +**GitHub:** the `production` environment (Settings → Environments) needs required reviewers, and its deployment branches and tags limited to `main` and the tag patterns `segment_analytics-v*` and `segment_analytics_plugin_*-v*`. The pub.dev environment requirement below only checks that the job names the environment; the reviewers are what make it a gate. + +**pub.dev, per package:** on the package's **Admin** tab, under **Automated publishing**, choose **Publishing from GitHub Actions** (not the Google Cloud service account option) and set: + +- **Repository:** `segmentio/analytics_flutter` +- **Tag pattern:** `-v{{version}}`, matching the tag above +- **Push** and **`workflow_dispatch`:** both enabled +- **Environment:** `production`, exactly as named in `publish.yml` + +Without this the publish step fails with an authorization error. + +## Changing the Flutter version + +The Flutter SDK is pinned and checksum-verified. To bump it, change all of these together: + +- `.github/actions/setup-flutter/action.yml` → default `flutter-version` +- `FLUTTER_VERSION` in `.github/workflows/ci.yml` and `.github/workflows/publish.yml` +- the `sha256:` input on every `setup-flutter` step in both workflows (one in `ci.yml`, two in `publish.yml`) + +Take the checksum for the `stable/linux/flutter_linux_-stable.tar.xz` archive from `https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json`. + +## Break-glass + +If automated publishing is unavailable, a pub.dev uploader can run `dart pub publish` locally from the package directory after `dart pub login`. Tag the release afterwards, noting that pushing the tag starts the workflow, which will fail on the already-published version; that is harmless.