update #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Push to `prod` -> tests run -> version is bumped automatically -> tag is | |
| # pushed -> publish.yml ships to pub.dev. | |
| # | |
| # Bump rules: | |
| # - If the pubspec.yaml version on prod is *not* yet tagged on the remote, | |
| # the workflow assumes you bumped it by hand and uses it as-is. | |
| # - Otherwise the latest commit subject decides the bump: | |
| # contains "breaking" or "BREAKING CHANGE" -> major (X+1.0.0) | |
| # starts with "feat" / "feat:" / "feat(...)" -> minor (X.Y+1.0) | |
| # anything else -> patch (X.Y.Z+1) | |
| # | |
| # The bump is committed back to `prod` with a `ci:` message. Self-commits | |
| # (by github-actions[bot]) are filtered out at the top so the workflow does | |
| # not re-trigger itself. | |
| # | |
| # An early `preflight` job compares HEAD's tree to the most recent release | |
| # tag's tree. If nothing tracked changed, the rest of the workflow is | |
| # skipped — so a no-op push to prod doesn't burn through a version number. | |
| name: Prod — Test, Bump & Tag | |
| on: | |
| push: | |
| branches: | |
| - prod | |
| permissions: | |
| contents: write # Needed to push the bump commit and the tag | |
| concurrency: | |
| group: prod-release | |
| cancel-in-progress: false | |
| jobs: | |
| preflight: | |
| name: Detect changes since last release | |
| runs-on: ubuntu-latest | |
| # Same self-commit filter as the release job — no point even running | |
| # preflight when the bot is responsible for the push. | |
| if: github.event.head_commit.author.email != 'github-actions[bot]@users.noreply.github.com' | |
| outputs: | |
| changed: ${{ steps.diff.outputs.changed }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 # Need history + tags for the diff | |
| ref: prod | |
| # Default GITHUB_TOKEN is fine here — preflight is read-only. | |
| - name: Compare HEAD against last release tag | |
| id: diff | |
| run: | | |
| set -euo pipefail | |
| LAST_TAG=$(git describe --tags --abbrev=0 --match='v[0-9]*' 2>/dev/null || true) | |
| if [[ -z "$LAST_TAG" ]]; then | |
| echo "No previous release tag found — first release. Proceeding." | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if git diff --quiet "$LAST_TAG" HEAD -- ; then | |
| echo "::notice::No tracked changes since $LAST_TAG — skipping release." | |
| echo "changed=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Tracked changes detected since $LAST_TAG — proceeding." | |
| echo "changed=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| release: | |
| name: Test, bump, tag | |
| needs: preflight | |
| if: needs.preflight.outputs.changed == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v5 | |
| with: | |
| fetch-depth: 0 # Full history + tags for accurate bump decisions | |
| ref: prod | |
| # PAT (not GITHUB_TOKEN) so the tag push at the end of this job | |
| # is attributed to a real account and triggers publish.yml. | |
| # GitHub blocks workflow_run cascades from GITHUB_TOKEN. | |
| token: ${{ secrets.RELEASE_PAT }} | |
| - name: Set up Dart | |
| uses: dart-lang/setup-dart@v1 | |
| - name: Install dependencies | |
| # `--no-example` skips auto-resolution of `./example/`, which can be a | |
| # Flutter app even when the package itself is pure Dart — Flutter is | |
| # not installed on this runner. | |
| run: dart pub get --no-example | |
| - name: Verify formatter | |
| # Scope to this package's own primary source. Avoids walking into | |
| # sub-projects (example/, smoke_test/) that carry their own pubspec | |
| # but aren't `pub get`-ed in CI — formatting them with no package | |
| # config triggers a language-version fallback and spurious diffs. | |
| run: | | |
| DIRS="" | |
| for d in lib test bin; do | |
| [ -d "$d" ] && DIRS="$DIRS $d" | |
| done | |
| if [ -z "$DIRS" ]; then | |
| echo "No primary source directories to format." | |
| exit 0 | |
| fi | |
| dart format --output=none --set-exit-if-changed $DIRS | |
| - name: Static analysis | |
| # Scope to this package's own primary source for the same reason as | |
| # the formatter step: `example/` (and friends) may target Flutter | |
| # and aren't `pub get`-ed on this Dart-only runner. | |
| run: | | |
| DIRS="" | |
| for d in lib test bin; do | |
| [ -d "$d" ] && DIRS="$DIRS $d" | |
| done | |
| if [ -z "$DIRS" ]; then | |
| echo "No primary source directories to analyze." | |
| exit 0 | |
| fi | |
| dart analyze --fatal-infos $DIRS | |
| - name: Run tests | |
| run: dart test | |
| - name: Decide next version | |
| id: bump | |
| env: | |
| COMMIT_MSG: ${{ github.event.head_commit.message }} | |
| run: | | |
| set -euo pipefail | |
| CURRENT=$(grep -E '^version:' pubspec.yaml | sed -E 's/^version:[[:space:]]*//; s/[[:space:]]+$//') | |
| if [[ -z "$CURRENT" ]]; then | |
| echo "::error::Could not parse version from pubspec.yaml" | |
| exit 1 | |
| fi | |
| echo "Current pubspec version: $CURRENT" | |
| # If the current version is *not* yet a tag on the remote, assume | |
| # the maintainer already bumped it. Use as-is. | |
| if git ls-remote --tags origin | grep -q "refs/tags/v${CURRENT}$"; then | |
| echo "Tag v${CURRENT} already exists -> auto-bump required." | |
| NEEDS_BUMP=true | |
| else | |
| echo "Tag v${CURRENT} does not exist -> using pubspec version as-is." | |
| NEEDS_BUMP=false | |
| fi | |
| if [[ "$NEEDS_BUMP" == "false" ]]; then | |
| NEXT="$CURRENT" | |
| else | |
| # Strip optional +build suffix so we can do arithmetic on the | |
| # numeric core; re-attach it (if any) at the end. | |
| CORE="${CURRENT%%+*}" | |
| BUILD="" | |
| if [[ "$CURRENT" == *"+"* ]]; then | |
| BUILD="+${CURRENT#*+}" | |
| fi | |
| IFS='.' read -r MAJOR MINOR PATCH <<< "$CORE" | |
| FIRST_LINE=$(printf '%s\n' "$COMMIT_MSG" | head -n 1) | |
| LOWER_FIRST=$(printf '%s' "$FIRST_LINE" | tr '[:upper:]' '[:lower:]') | |
| if printf '%s' "$COMMIT_MSG" | grep -qiE 'breaking change|^breaking:|^[a-z]+(\([^)]*\))?!:'; then | |
| MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 | |
| BUMP_KIND="major" | |
| elif [[ "$LOWER_FIRST" =~ ^feat([\(:[:space:]]|$) ]]; then | |
| MINOR=$((MINOR + 1)); PATCH=0 | |
| BUMP_KIND="minor" | |
| else | |
| PATCH=$((PATCH + 1)) | |
| BUMP_KIND="patch" | |
| fi | |
| NEXT="${MAJOR}.${MINOR}.${PATCH}${BUILD}" | |
| echo "Bump kind: $BUMP_KIND -> $CURRENT -> $NEXT" | |
| fi | |
| { | |
| echo "CURRENT=$CURRENT" | |
| echo "NEXT=$NEXT" | |
| echo "NEEDS_BUMP=$NEEDS_BUMP" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Apply version bump | |
| if: steps.bump.outputs.NEEDS_BUMP == 'true' | |
| env: | |
| CURRENT: ${{ steps.bump.outputs.CURRENT }} | |
| NEXT: ${{ steps.bump.outputs.NEXT }} | |
| run: | | |
| # Use a literal-string sed: replaces the exact `version: $CURRENT` | |
| # line with `version: $NEXT`. Anchored to start-of-line so a | |
| # `version:` appearing elsewhere (a doc comment, etc.) is ignored. | |
| sed -i.bak -E "s/^version:[[:space:]]+.*$/version: ${NEXT}/" pubspec.yaml | |
| rm pubspec.yaml.bak | |
| echo "pubspec.yaml updated:" | |
| grep -E '^version:' pubspec.yaml | |
| - name: Update CHANGELOG.md if entry missing | |
| env: | |
| NEXT: ${{ steps.bump.outputs.NEXT }} | |
| COMMIT_MSG: ${{ github.event.head_commit.message }} | |
| run: | | |
| set -euo pipefail | |
| # Skip if the changelog already mentions this version anywhere. | |
| # pana matches both `## 1.2.3` and `## [1.2.3]`, so just grep for | |
| # the bare version string after a `## ` heading. | |
| if grep -qE "^## \[?v?${NEXT//./\\.}\]?(\b|[[:space:]]|$)" CHANGELOG.md 2>/dev/null; then | |
| echo "CHANGELOG.md already mentions v${NEXT} — leaving it alone." | |
| exit 0 | |
| fi | |
| # Build a bullet list from the commit message: the subject becomes | |
| # the headline bullet; body lines starting with "- " or "* " are | |
| # included as their own bullets. | |
| SUBJECT=$(printf '%s\n' "$COMMIT_MSG" | head -n 1 | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') | |
| BODY_BULLETS=$(printf '%s\n' "$COMMIT_MSG" | awk 'NR>1 && /^[[:space:]]*[-*][[:space:]]/ { sub(/^[[:space:]]*[-*][[:space:]]/, ""); print "- " $0 }') | |
| { | |
| echo "## [${NEXT}]" | |
| echo "" | |
| if [[ -n "$SUBJECT" ]]; then | |
| echo "- ${SUBJECT}" | |
| else | |
| echo "- Release ${NEXT}" | |
| fi | |
| if [[ -n "$BODY_BULLETS" ]]; then | |
| echo "$BODY_BULLETS" | |
| fi | |
| echo "" | |
| } > /tmp/changelog-entry.md | |
| if [[ ! -f CHANGELOG.md ]]; then | |
| { echo "# Changelog"; echo ""; cat /tmp/changelog-entry.md; } > CHANGELOG.md | |
| else | |
| # Insert the new section directly after the first `# ` heading. | |
| awk -v entry_file=/tmp/changelog-entry.md ' | |
| BEGIN { inserted = 0 } | |
| { | |
| if (!inserted && $0 ~ /^# /) { | |
| print "" | |
| while ((getline line < entry_file) > 0) print line | |
| close(entry_file) | |
| inserted = 1 | |
| } | |
| } | |
| ' CHANGELOG.md > CHANGELOG.md.new | |
| mv CHANGELOG.md.new CHANGELOG.md | |
| fi | |
| echo "CHANGELOG.md updated." | |
| - name: Commit bump back to prod | |
| id: commit | |
| env: | |
| NEXT: ${{ steps.bump.outputs.NEXT }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git add pubspec.yaml CHANGELOG.md | |
| if git diff --staged --quiet; then | |
| echo "No bump-related changes to commit." | |
| echo "COMMITTED=false" >> "$GITHUB_OUTPUT" | |
| else | |
| git commit -m "ci: release v${NEXT}" | |
| git push origin HEAD:prod | |
| echo "COMMITTED=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Create & push tag | |
| env: | |
| NEXT: ${{ steps.bump.outputs.NEXT }} | |
| run: | | |
| set -euo pipefail | |
| TAG="v${NEXT}" | |
| # Re-check the remote in case a parallel push beat us to it. | |
| if git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then | |
| echo "Tag ${TAG} already on origin; skipping." | |
| exit 0 | |
| fi | |
| git tag -a "${TAG}" -m "Release ${TAG}" | |
| git push origin "${TAG}" | |
| echo "Pushed tag ${TAG}. publish.yml will take it from here." |