Publishing to pub.dev With Zero Stored Secrets: OIDC and Per-Package Tags for a Federated Flutter Plugin

Every CI publishing pipeline I have ever set up began the same way: generate a long-lived token on the registry website, paste it into the repo's secret store, and promise myself I'd rotate it someday. (Narrator: he did not rotate it.)

I've paid for that pattern before. On an earlier npm project, my tag-driven publish pipeline kept a stored NPM_TOKEN — and over its lifetime I collected the full bingo card: a release that failed because the credential had quietly expired, and a 409 Conflict because I'd manually published a version while CI was trying to publish the same one. The token wasn't just a secret to protect; it was a second release actor with its own opinions.

So when I set up automated publishing for ble_proximity_signal — a federated Flutter plugin that maps BLE signal strength to a proximity intensity — I wanted the new thing pub.dev supports: OIDC-based publishing, with no stored credentials at all.

This post walks through the actual setup: the workflow file, the per-package tag scheme, and the one constraint that surprised me. Full disclosure up front: the pipeline is configured and reviewed, but as of this writing it has not fired yet — every version currently on pub.dev was published by hand, including 0.2.1, which I shipped manually while building the automation to replace me. The irony is not lost on me.

The problem multiplied by four

ble_proximity_signal is a federated plugin, which means it's not one package but four, living in one monorepo:

  • ble_proximity_signal — the app-facing package
  • ble_proximity_signal_platform_interface
  • ble_proximity_signal_android
  • ble_proximity_signal_ios

Manual publishing means running dart pub publish up to four times, in dependency order, without typos. It's the kind of chore that's fine exactly until the day you're tired, and BLE work makes you tired.

What OIDC actually changes

With OIDC (OpenID Connect), the trust relationship inverts. Instead of you storing a registry credential in GitHub, pub.dev stores a description of which GitHub repository and tag pattern it trusts. When the workflow runs, GitHub mints a short-lived identity token for that specific run, and pub.dev verifies it against the trust config. Nothing long-lived exists. Nothing can leak from the secret store, because there is nothing in the secret store.

The pub.dev side is a one-time setup per package, in the package's Admin → Automated publishing tab:

  • Repository: AndrewDongminYoo/ble_proximity_signal
  • Tag pattern: <package_name>-v{{version}}

The GitHub side is a single 60-line workflow.

The workflow

Here is the real .github/workflows/publish.yml, trimmed to the interesting parts:

permissions: read-all

on:
  push:
    tags:
      - ble_proximity_signal-v[0-9]+.[0-9]+.[0-9]+
      - ble_proximity_signal_platform_interface-v[0-9]+.[0-9]+.[0-9]+
      - ble_proximity_signal_ios-v[0-9]+.[0-9]+.[0-9]+
      - ble_proximity_signal_android-v[0-9]+.[0-9]+.[0-9]+
jobs:
  platform_interface:
    if: startsWith(github.ref, 'refs/tags/ble_proximity_signal_platform_interface-v')
    permissions:
      id-token: write # Required for OIDC authentication with pub.dev.
      contents: read # Required for the reusable workflow to check out the repo.
    uses: dart-lang/setup-dart/.github/workflows/publish.yml@65eb853c7ba17dde3be364c3d2858773e7144260 # v1.7.2
    with:
      working-directory: ble_proximity_signal_platform_interface

Three sibling jobs are identical except for the if: prefix and the working-directory. A few details worth noticing:

  • permissions: read-all at the top, elevation per job. Only the publish jobs get id-token: write, and that's the entire OIDC handshake from the repo's perspective. One permission line replaces the token, the rotation schedule, and the leak surface.
  • The heavy lifting is delegated to dart-lang/setup-dart's reusable publish workflow, pinned to a commit SHA rather than a floating tag. I did not feel like reimplementing dart pub publish in YAML, and neither should you.
  • Each tag matches exactly one job. Push ble_proximity_signal_android-v0.2.2 and only the Android package publishes. The public package and the iOS sibling don't move.

Releasing becomes two commands:

git tag ble_proximity_signal-v0.2.2
git push origin ble_proximity_signal-v0.2.2

Why per-package tags, and not just v0.2.2

Because I've been burned by the alternative. In another monorepo of mine — a grab-bag of user scripts — releases used a single repo-wide tag series, and every package's release pipeline pointed at the repo's releases/latest. The day package A cut a release, users fetching package B started getting package A's assets. The repo-wide tag made every release a shared mutable pointer.

Package-prefixed tags (<package_name>-v<semver>) give each package an independent release history in the same repo. The tag itself carries the routing information — the startsWith guards in the workflow are just reading it back out.

There's a matching human rule in the repo's RELEASING.md: publish only the packages that actually changed. A version bump with no changes is pure noise on pub.dev, and now that each package has its own trigger, there's no mechanical pressure to bump everything in lockstep.

(The old repo-wide tags v0.1.0 and v0.2.0 are still sitting in the history, matching none of the new patterns. They're fossils now. I've decided to think of them as load-bearing fossils and leave them alone.)

The guard I didn't have to build

My favorite property of the whole setup is one I got for free: pub.dev rejects the publish if the tag version doesn't exactly match pubspec.yaml.

That sounds minor until you've seen the failure mode it prevents. In my Homebrew tap, one release trigger drove version-bump automation that covered only some of the shipped artifacts — the uncovered formula silently kept its stale version and checksum while CI stayed green. Nothing failed; the artifact just quietly lagged reality. The fix there was widening the automation and adding a version-consistency check in front of the tag.

pub.dev builds that check into the registry itself. Forget to bump the pubspec, push the tag anyway, and the publish fails loudly instead of shipping a lie. It's the pre-tag lint I had to hand-build elsewhere, except someone else maintains it.

The bootstrap constraint

One genuine surprise: OIDC cannot publish the very first version of a package. The Admin tab where you enable automated publishing only exists after the package exists, so version one of each of the four packages had to go out by hand — dart pub publish, four times, in dependency order. The robot cannot be born without a human signing the birth certificate.

That's the actual reason the current pub.dev history (0.1.0+1, 0.2.0, 0.2.1 on the app package, as of July 2026) is entirely manual. The automation exists to make sure that history stops growing by hand.

A footgun I found while writing this

Writing things down is the cheapest audit there is. While quoting the tag patterns above, I noticed that the very first published versions were 0.1.0+1 — and the trigger pattern [0-9]+.[0-9]+.[0-9]+ does not match a +1 build suffix. If a future version ever ships as x.y.z+n, its tag will match nothing and the workflow will sit there doing what it does best: nothing. Future me: widen the pattern before tagging a build-suffixed release. Present me: at least I wrote it down.

Lessons learned

  1. Delete the credential, not just protect it. Every stored token is a rotation schedule, a leak surface, and a second publish actor that can race you. OIDC removes the object instead of guarding it.
  2. In a monorepo, the tag is the router. <package_name>-v<semver> turns "which package does this release belong to" from tribal knowledge into something the workflow can startsWith on.
  3. Prefer guards that live in the registry. The tag==pubspec check catches the "silently stale artifact" failure that I've had to hand-build a lint for on other platforms.
  4. Blog your infrastructure before you trust it. I found the build-suffix gap not by running the pipeline but by explaining it. Explaining is testing, at a discount.

The next release of ble_proximity_signal will be the pipeline's first real flight. If you've set up pub.dev's automated publishing yourself — or if you can see another gap in that tag pattern I've missed — I'd genuinely like to hear about it in the comments.