Skip to content

update

update #1

Workflow file for this run

# 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 }
{
print
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."