diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0ea59d8 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,25 @@ +name: Publish to pub.dev + +# Publishes to pub.dev when a version tag is pushed. Runs in a TAG ref context — +# the only refType pub.dev's automated publishing authorizes (branch pushes are +# rejected). release.yml creates the tag on merge (via RELEASE_PAT) to trigger +# this. +# +# Uses the Dart team's official reusable publish workflow, which performs the +# pub.dev OIDC token exchange and runs `dart pub publish` WITH validation +# (no --force). It is the recommended, maintained path — more robust and secure +# than a hand-rolled publish step. +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+*" + +# Least privilege at the top level; the reusable workflow's job requests the +# id-token it needs. No write scopes are granted here. +permissions: {} + +jobs: + publish: + permissions: + id-token: write # OIDC token for keyless pub.dev auth + uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c85903..571ab81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,17 @@ name: Release # On push to the default branch (a merged PR): if the pubspec version isn't yet -# tagged, publish to pub.dev (keyless via OIDC), then tag + create a GitHub -# Release. Replaces the old tag-triggered publish.yml — one coherent -# merge -> release -> publish flow, no PAT needed. +# tagged, push the tag v and create a GitHub Release. The tag then +# triggers publish.yml, which publishes to pub.dev FROM A TAG REF (pub.dev's +# automated publishing only allows the 'tag' refType, not 'branch'). # -# pub.dev publishing requires the package's automated-publishing to be enabled -# and this repo + tag pattern authorized (pub.dev admin -> Automated publishing). +# The tag is PUSHED VIA GIT using PUB_RELEASE_TOKEN, because a tag pushed with +# the default GITHUB_TOKEN does NOT trigger other workflows — the PAT-attributed +# push is what makes publish.yml fire. The GitHub Release itself is created with +# the default GITHUB_TOKEN (reliable release perms in-workflow), so the PAT only +# needs `contents: write` for the git push, not access to the releases API. +# If PUB_RELEASE_TOKEN is unset, the tag is pushed with the default token (still +# tagged + released, but publish.yml won't auto-fire — push the tag manually). on: push: branches: ["master"] @@ -17,11 +22,10 @@ permissions: jobs: release: - name: Publish and release + name: Tag and release runs-on: ubuntu-latest permissions: - contents: write # tag + GitHub Release - id-token: write # pub.dev keyless (OIDC) auth + contents: write # create the GitHub Release with GITHUB_TOKEN steps: - uses: actions/checkout@v6 with: @@ -31,41 +35,29 @@ jobs: id: v run: | version=$(grep -E '^version:' pubspec.yaml | head -1 | awk '{print $2}' | tr -d '\r') - echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=v$version" >> "$GITHUB_OUTPUT" - - name: Skip if already released - id: guard + - name: Push tag (triggers pub.dev publish) + id: tag env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # A PAT push is what lets publish.yml trigger; GITHUB_TOKEN falls back. + TOKEN: ${{ secrets.PUB_RELEASE_TOKEN || secrets.GITHUB_TOKEN }} TAG: ${{ steps.v.outputs.tag }} run: | - if gh release view "$TAG" >/dev/null 2>&1 \ - || git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then - echo "skip=true" >> "$GITHUB_OUTPUT" + if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then echo "Tag $TAG already exists — skipping." - else - echo "skip=false" >> "$GITHUB_OUTPUT" + echo "created=false" >> "$GITHUB_OUTPUT" + exit 0 fi - - if: steps.guard.outputs.skip == 'false' - uses: dart-lang/setup-dart@v1 - with: - sdk: stable - # Use Flutter for Flutter packages; dart-only packages can drop this step. - - if: steps.guard.outputs.skip == 'false' - uses: subosito/flutter-action@v2 - with: - channel: stable - - if: steps.guard.outputs.skip == 'false' - name: Install dependencies - run: flutter pub get || dart pub get - - if: steps.guard.outputs.skip == 'false' - name: Publish to pub.dev - run: dart pub publish --force - - if: steps.guard.outputs.skip == 'false' - name: Tag and GitHub Release + git tag "$TAG" "${{ github.sha }}" + # Push over an authenticated URL built from the token; no persisted creds. + git push "https://x-access-token:${TOKEN}@github.com/${{ github.repository }}.git" "$TAG" + echo "created=true" >> "$GITHUB_OUTPUT" + echo "Pushed $TAG ✓ (publish.yml will publish to pub.dev)" + - name: Create GitHub Release + if: steps.tag.outputs.created == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.v.outputs.tag }} run: | - gh release create "$TAG" --target "${{ github.sha }}" --title "$TAG" --generate-notes - echo "Released + published $TAG ✓" + gh release create "$TAG" --title "$TAG" --generate-notes + echo "Released $TAG ✓" diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 922a41f..ede8349 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -1,7 +1,12 @@ name: Version Check -# Every PR must bump the pubspec.yaml version. Release tags it on merge; the -# existing publish workflow then publishes to pub.dev on that tag. +# Every PR must set pubspec.yaml to a version STRICTLY GREATER than everything +# actually RELEASED — the latest version on pub.dev and the highest git tag. +# The base-branch pubspec is deliberately NOT a floor: master's version may be +# ahead of what's been published/tagged (e.g. a bump whose publish failed), and +# a version that was never released must remain releasable. This prevents a +# non-incremental publish (which pub.dev rejects) while still allowing the +# current master version to ship if it hasn't been released yet. on: pull_request: branches: ["master"] @@ -17,31 +22,67 @@ jobs: steps: - uses: actions/checkout@v6 with: - fetch-depth: 0 # need the base branch to diff the version + fetch-depth: 0 # need base branch + tags to compare persist-credentials: false - - name: Read versions + + - name: Determine highest released version id: v run: | - git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" + # Semver core only (drop any +build suffix): pub.dev/tags use x.y.z. + core() { echo "${1%%+*}"; } read_ver() { grep -E '^version:' "$1" | head -1 | awk '{print $2}' | tr -d '\r'; } - pr=$(read_ver pubspec.yaml) - git show "origin/${{ github.base_ref }}:pubspec.yaml" > /tmp/base_pubspec.yaml - base=$(read_ver /tmp/base_pubspec.yaml) + + pr=$(core "$(read_ver pubspec.yaml)") + + # 1) latest published version on pub.dev (404 for a new package -> 0.0.0) + name=$(grep -E '^name:' pubspec.yaml | head -1 | awk '{print $2}' | tr -d '\r') + pubdev=$(curl -fsSL "https://pub.dev/api/packages/$name" 2>/dev/null \ + | python3 -c "import sys,json;print(json.load(sys.stdin).get('latest',{}).get('version','0.0.0'))" 2>/dev/null || echo "0.0.0") + pubdev=$(core "${pubdev:-0.0.0}") + + # 2) highest git tag (the last actually-released tag) + tag=$(git ls-remote --tags origin 2>/dev/null | sed -E 's#.*refs/tags/##' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | sed 's/^v//' | sort -V | tail -n1) + tag=${tag:-0.0.0} + + # highest ACTUALLY-RELEASED version (pub.dev + tags only, NOT base pubspec) + highest=$(printf '%s\n%s\n' "$pubdev" "$tag" | sort -V | tail -n1) + echo "pr=$pr" >> "$GITHUB_OUTPUT" - echo "base=$base" >> "$GITHUB_OUTPUT" - - name: Compare + echo "highest=$highest" >> "$GITHUB_OUTPUT" + echo "pubdev=$pubdev tag=$tag -> highest released=$highest ; PR=$pr" + + - name: Require PR version to exceed the highest released + env: + PR: ${{ steps.v.outputs.pr }} + HIGHEST: ${{ steps.v.outputs.highest }} + run: | + if [ "$PR" = "$HIGHEST" ]; then + echo "::error::pubspec version $PR already exists (published/tagged). Bump above $HIGHEST." + exit 1 + fi + top=$(printf '%s\n%s\n' "$HIGHEST" "$PR" | sort -V | tail -n1) + if [ "$top" != "$PR" ]; then + echo "::error::pubspec version $PR is not greater than the highest released version $HIGHEST." + exit 1 + fi + echo "Version $PR is ahead of the highest released ($HIGHEST) ✓" + + - name: Require CHANGELOG entry for this version env: PR: ${{ steps.v.outputs.pr }} - BASE: ${{ steps.v.outputs.base }} run: | - echo "base=$BASE pr=$PR" - if [ "$PR" = "$BASE" ]; then - echo "::error::pubspec.yaml version not bumped (still $BASE)." + # pub.dev rejects a publish whose CHANGELOG doesn't mention the version, + # so enforce it here (at the PR gate) instead of failing at publish time. + if [ ! -f CHANGELOG.md ]; then + echo "::error::CHANGELOG.md is missing — add it with an entry for $PR." exit 1 fi - greater=$(printf '%s\n%s\n' "$BASE" "$PR" | sort -V | tail -n1) - if [ "$greater" != "$PR" ]; then - echo "::error::pubspec.yaml version $PR is lower than base $BASE." + # Match the version as a whole token (e.g. "## [2.4.0]", "## 2.4.0"), + # not as a substring of a longer version. + if grep -qE "(^|[^0-9.])${PR//./\\.}([^0-9.]|$)" CHANGELOG.md; then + echo "CHANGELOG.md mentions $PR ✓" + else + echo "::error::CHANGELOG.md has no entry for $PR. Add a '## $PR' section describing the release." exit 1 fi - echo "Version bumped $BASE -> $PR ✓" diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 7f0524e..9bc9c41 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -31,6 +31,8 @@ jobs: name: Setup Flutter and Deploy to GitHub Pages runs-on: ubuntu-latest needs: check_up_to_date + permissions: + contents: write # flutter-gh-pages pushes the built demo to the gh-pages branch steps: - name: Checkout Code uses: actions/checkout@v4 @@ -47,8 +49,10 @@ jobs: - name: Get Flutter Dependencies run: flutter pub get + # Don't block the demo deploy on info/style lints — the CI workflow lints + # separately. Only real errors should stop the Pages publish. - name: Run Flutter Analyzer (Lint) - run: flutter analyze + run: flutter analyze --no-fatal-infos --no-fatal-warnings - name: Deploy to GitHub Pages uses: bluefireteam/flutter-gh-pages@v8 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1421932..12be27f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 3.1.1 + +- **Chore**: Added automated pub.dev release pipeline (version check, tag, publish) via GitHub Actions. + ## 3.1.0 - **Security**: Added `SECURITY.md` for vulnerability reporting and security best practices.