Skip to content

ci: automate release tagging, pub.dev publishing and GitHub release - #122

Merged
muzahidul-opti merged 4 commits into
masterfrom
ci/release-workflow
Oct 15, 2026
Merged

muzahidul-opti merged 4 commits into
masterfrom
ci/release-workflow

Conversation

@muzahidul-opti

@muzahidul-opti muzahidul-opti commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Automates everything after the release PR merges. It follows the Swift SDK's release.yml (swift-sdk #664), adapted for pub.dev, which only accepts publishing from a run triggered by a tag push.

sequenceDiagram
    participant PR as Release PR (pubspec version bump)
    participant R as release.yml
    participant P as publish.yml
    PR->>R: pull_request: validate only
    Note over R: version format, package_info.dart + README match,<br/>CHANGELOG "## X.Y.Z" exists, pub publish --dry-run
    PR->>R: merge to master / N.x
    R->>P: push tag vX.Y.Z (CI_USER_TOKEN)
    Note over P: environment pub.dev → reviewer approves
    P->>P: job publish: check tag is on master/N.x + CHANGELOG notes,<br/>flutter pub publish --force (OIDC, no stored secret)
    P->>P: job github-release: gh release create from CHANGELOG section<br/>(-rc → prerelease; N.x → not latest)
Loading
.github/workflows/
├── release.yml   # PR: validate. Merge: push tag. Already tagged → no-op
└── publish.yml   # on tag v*: job publish → job github-release (re-run failed jobs retries only the release)
CLAUDE.md         # release steps 6–7 now automated
  • Why the tag is pushed with CI_USER_TOKEN: a tag pushed with the default GITHUB_TOKEN does not start other workflows, so publish.yml would never run.
  • Why the GitHub release comes after the publish: it only appears once the package is actually on pub.dev.
  • Maintenance branches: both triggers include the [0-9]+.x pattern, so 3.x patches work once these files are cherry-picked to 3.x. Releases from those branches are created with --latest=false.

Evidence

Simulated release.yml's validation against a clean checkout of master (3.8.0):

version ok: 3.8.0 / package_info ok / README ok / changelog section ok
tag exists -> would skip                        # merging #119 (it touches pubspec.yaml) is a no-op
regex accepts 4.0.0-rc.1, 4.0.0, 3.8.1 · rejects 4.0
flutter pub publish --dry-run → Package has 0 warnings. (exit 0)
actionlint → ok

Ran release.yml on GitHub via throwaway draft PR #123 (4.0.0 bump, closed unmerged): run 38761027841

Validate version: success
Validate package: success   # Package has 0 warnings.
Push tag: skipped           # pull_request event, no v4.0.0 tag created

The first real run will be 4.0.0. Since that's a real release, the pipeline gets checked in two steps: release.yml validates the 4.0.0 release PR (including the pub.dev dry-run) before it merges, and publish.yml waits for a reviewer's approval before publishing. If authentication fails, nothing is published and the run can be retried.

Before merging: one-time setup

  1. Sign in to pub.dev with the package's uploader account (the one in the internal release docs; the package has no verified publisher). At pub.dev/packages/optimizely_flutter_sdk/admin, under Automated publishing, enable publishing from GitHub Actions: repository optimizely/optimizely-flutter-sdk, tag pattern v{{version}}, and enable Require GitHub Actions environment with the name pub.dev.
  2. In the repo settings, under Environments, create pub.dev and add required reviewers. Without reviewers, merging a version bump publishes with no approval step.
  3. Confirm CI_USER_TOKEN can create tags in this repo (contents: write).

After this one login, releases need no pub.dev credentials: GitHub issues each run a short-lived OIDC token that pub.dev checks against the step 1 settings. Manual flutter pub publish keeps working as a fallback.

Until step 1 is done, publish.yml fails at authentication. That's harmless: nothing is published, and the run can be retried afterwards.

Merge Danger

Door: two-way for the workflows (revert the files). Each publish is one-way: pub.dev versions can't be deleted, only retracted.
Blast Radius:

  • Runs only when pubspec.yaml changes on PRs or merges to master/N.x, or on v* tags. Normal CI is unaffected.
  • A release PR now fails if the CHANGELOG section is missing or the 3 version files disagree.
  • Still manual: announcing the release to the documentation team. The internal release docs need their publish and GitHub release steps replaced once this merges.
  • The /release Claude skill becomes redundant. Running it after a merge would race the pipeline, and the second publish fails because the version already exists. Remove it once 4.0.0 ships through this pipeline.

🤖 Generated with Claude Code

muzahidul-opti and others added 2 commits October 14, 2026 20:54
release.yml validates version files and the CHANGELOG section on release PRs,
and pushes tag v<version> when the version change merges. publish.yml publishes
the tag to pub.dev via OIDC and creates the GitHub release from the CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +29 to +30
- name: Publish to pub.dev
run: flutter pub publish --force

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-running this job can't recover if the GitHub release step fails. Publishing and the GitHub release are steps in one job. If the publish succeeds and gh release create then fails (API error, tag problem, etc.), re-running runs flutter pub publish --force again. That fails because the version already exists on pub.dev, so the job never gets back to the release step. This contradicts the CLAUDE.md line "If either workflow fails, re-run it from the Actions tab."

Suggestion: split into two jobs, publish, then github-release with needs: publish. "Re-run failed jobs" then retries only the release job. Side benefit: only publish needs id-token: write and the pub.dev environment, and only github-release needs contents: write. Right now one job holds both permissions while running third-party actions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the re-run couldn't recover: pub.dev rejects a second upload of the same version. Fixed in bee1622 by splitting into two jobs:

  • publish: contents: read + id-token: write, runs in the pub.dev environment
  • github-release: needs: publish, contents: write only, no third-party actions

"Re-run failed jobs" now retries only the release. The CLAUDE.md line now says to use "Re-run failed jobs", which matches the new behavior.

GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
awk -v h="## $VERSION" '$0==h{f=1;next} /^## [0-9]/{if(f)exit} f' CHANGELOG.md > notes.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Release notes aren't checked until after the irreversible publish. The CHANGELOG check only lives in release.yml. A tag pushed by hand (the tag pattern allows it) skips it, and if the heading format is slightly off, this awk silently writes an empty notes.md. By then the package is already on pub.dev.

Suggestion: extract the notes before the publish step and fail if the file is empty, e.g. [ -s notes.md ] || { echo "No CHANGELOG section for $VERSION"; exit 1; }. If you split the jobs, the notes can be uploaded as an artifact or extracted again in the release job.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on hand-pushed tags. Fixed in bee1622: a Check tag step now runs before setup and publish. It extracts the notes and fails with CHANGELOG.md has no '## X.Y.Z' section if the file is empty. github-release extracts them again (same awk), which is simpler than passing an artifact between jobs.

A note on the heading scenario: on the normal path, release.yml (grep -qxF "## $VERSION") and this awk ($0==h) use the same exact-line match, so the two can't disagree. The real gap was hand-pushed tags skipping release.yml, and the new check covers that.

Tested locally: the v3.8.0 tag passes (16 note lines), and a tag for a version with no CHANGELOG section fails.

Comment thread .github/workflows/release.yml Outdated
# On a release PR it only validates, so a missing CHANGELOG section or a version mismatch fails before merge.
# Run manually (workflow_dispatch) to retry a failed release from the branch's latest commit.
on:
workflow_dispatch:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workflow_dispatch can be started from any branch. The branches: filter only applies to push/pull_request. Anyone with write access can pick a feature branch in the "Run workflow" dropdown. If that branch's pubspec.yaml has an unreleased version, the workflow tags that unmerged commit and starts a publish. The pub.dev environment reviewer is the only safeguard, and the reviewer sees just a version number.

Suggestion: guard the Push tag step, e.g.

if: >-
  steps.version.outputs.version && github.event_name != 'pull_request' &&
  (github.ref == 'refs/heads/master' || (startsWith(github.ref, 'refs/heads/') && endsWith(github.ref, '.x')))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this was a hole, but guarding the Push tag step only covers one way in: anyone with the same write access can git push origin v9.9.9 from any commit, which triggers publish.yml directly and bypasses release.yml. So I fixed it in two places instead (bee1622):

  • Removed workflow_dispatch. Its only job was retrying a failed tag push, and re-running the original push run (which ran on master or N.x) already does that.
  • publish.yml now checks branch ancestry before publishing: the tagged commit must be reachable from origin/master or origin/N.x, otherwise the job fails with vX.Y.Z is not on master or a maintenance branch. This covers manual dispatches and hand-pushed tags. (It also avoids endsWith(github.ref, '.x'), which would match a branch like feature/foo.x.)

Tested locally: v3.8.0 on master passes, a commit on 3.x passes, and a commit that's only on a feature branch fails.

Optionally, a tag ruleset restricting who can create v* tags would add another layer. That's a repo settings change, so it isn't in this PR.

@jaeopt jaeopt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me with a few suggestions from AI.

- publish and github-release are separate jobs, so a failed release step
  can be retried without re-running the publish
- check the tag is on master or N.x and has a CHANGELOG section before
  the irreversible publish (covers tags pushed by hand)
- drop workflow_dispatch from release.yml; re-running the push run retries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@muzahidul-opti
muzahidul-opti requested a review from jaeopt October 15, 2026 16:52

@jaeopt jaeopt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@muzahidul-opti
muzahidul-opti merged commit b9db7e2 into master Oct 15, 2026
9 checks passed
@muzahidul-opti
muzahidul-opti deleted the ci/release-workflow branch October 15, 2026 17:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants