Skip to content

ci: dev → main pipeline with tokenless publishing to crates.io - #455

Open
MattJackson wants to merge 1 commit into
tiberius-rs:mainfrom
MattJackson:ci/release-trusted-publishing
Open

MattJackson wants to merge 1 commit into
tiberius-rs:mainfrom
MattJackson:ci/release-trusted-publishing

Conversation

@MattJackson

@MattJackson MattJackson commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

@aqrln, this is the GitHub Actions setup you asked for in #454, so every release can go out
from CI through Trusted Publishing with no stored token. While I was at it I went through all
of our CI end to end, and I'd like to propose a small change to how code flows into main.
The goal is to make things faster for maintainers and safer at the same time. Here's the
what and the why.

What we have today

  • PRs go straight into main. The ruleset requires a PR but no approvals and no passing
    checks
    , so a PR can be merged while CI is red.
  • On a push to main, "Cargo tests" only runs fmt, the MSRV check and the rustls unit tests.
    Clippy is advisory. The real SQL Server tests run on PRs only, and nothing requires the PR to
    be up to date with main. So the commit that actually lands on main may never have been
    tested as-is.
  • The macOS and Windows jobs only trigger on pushes to qa, which is stale, and the smoke
    job's dev condition refers to a branch that doesn't exist. Those lanes never run.
  • Semver breaks are advisory only, so nothing stops a breaking change going out as a patch.
  • Publishing is manual and needs someone with a crates.io token. That's what happened with
    0.13.0: tagged, but not published until you did it by hand.

What this PR sets up

Stage What happens CI Maintainer effort
PR → dev contributors open PRs as usual (dev becomes the default branch) ci.yml: fmt, strict clippy, lib tests for four feature sets, MSRV, docs, cargo-deny, gitleaks, advisory semver, one real-server smoke run. About 10 minutes. review
Merge into dev "Merge when ready" puts the PR in the merge queue qa.yml runs the full suite on the exact commit that will land: SQL Server 2017/2019/2022/Edge × 7 feature sets, macOS, Windows, and strict semver against the last published version. If it's red, the PR bounces back and dev is never broken. none
Release: PR dev → main whenever you want to ship: dev already has the version and CHANGELOG bump release.yml gate: the merged tree must be identical to a dev tree that passed QA, and it checks the version, changelog, tag and macro-crate version one click ("Create a merge commit")
Merge to main the release release.yml publishes tiberius-macros (if bumped) and tiberius via Trusted Publishing, checks the published checksum, and creates the tag and GitHub Release none

main always equals the latest crates.io release, and it keeps dev's real commits, because
releases are merge commits. That means git log main shows exactly what shipped in each
release, and the next dev → main PR is always a clean merge.

Why this is better

Faster for maintainers

  • Reviewing stays the same. Approve and click "Merge when ready"; the queue does the heavy
    testing and merges it for you. No waiting on a 45-minute matrix before clicking merge, and
    no watching CI afterwards.
  • Releasing is one PR, dev → main, and one click. No tagging, no local cargo publish,
    no tokens.
  • The heavy suite runs once per approved change, in the queue, not on every contributor push
    and not again at release time. PR feedback stays around 10 minutes.
  • If the heavy suite fails, the PR bounces back to its author with the failure attached,
    instead of a broken branch that someone has to notice and fix.

Safer

  • Nothing reaches dev without the full suite passing on the exact merge result.
  • Nothing reaches crates.io unless main's tree is byte-for-byte a tree that passed that
    suite. A change that bypasses dev makes the release gate fail.
  • The release gate fails loudly on a missing version bump (every PR into main is a
    release), a version going backwards, a macro crate changed without its version changing, a
    missing CHANGELOG entry, a clashing tag, or an unpublished dependency version. After
    publishing, it checks the crates.io checksum matches what was built.
  • There are no long-lived secrets. Publishing uses short-lived OIDC tokens bound to this repo
    and this workflow, and every action is pinned by SHA (Dependabot keeps the pins current).
  • .github/ changes need a maintainer's review (CODEOWNERS), so the release path can't change
    unnoticed.

Nothing is lost. Every job in test.yml, security.yml and pr-code-security.yml is
kept, and the lanes that never ran now do:

Old New
clippy (advisory) ci.yml clippy, strict (--all-targets --features=all -D warnings), plus a rustls-webpki-roots lane
format ci.yml rustfmt
msrv ci.yml MSRV (1.88)
rustls-features ci.yml lib tests, 4 feature sets
semver (advisory) ci.yml advisory semver, plus new strict semver in qa.yml against crates.io
smoke (4×7 on every PR) ci.yml SQL 2022 smoke on every PR, plus the full 4×7 in qa.yml in the merge queue
integration-macos (never ran) qa.yml macOS, now green
integration-windows (never ran) qa.yml Windows, now green
security.yml cargo-deny ci.yml cargo-deny (PRs, queue, weekly)
pr-code-security.yml gitleaks ci.yml gitleaks (every PR and the queue)

Proof

I ran the whole pipeline on my fork before opening this, including two back-to-back
releases (all runs at https://github.com/MattJackson/tiberius/actions/runs/<id>):

What Result Run
ci.yml on this PR green 36216753912
Release gate on this PR (first run, no release.yml on the base yet) green 36216753843
Its push to main green, nothing to publish 36216926766
A PR into main without a version bump red, as intended 36216972958
Release 1: QA on dev (35/35 jobs, incl. macOS + Windows) green 36216957765
Release 1: dev → main PR gate green 36218045213
Release 1: push to main, publish dry run green 36218246059
Release 2: QA on dev green 36218296726
Release 2: dev → main PR gate (clean merge) green 36219670977
Release 2: push to main, publish dry run green 36219857047

The release-gate logic also has 29 unit tests, which run in CI.

Not proven on a fork: the real crates.io token exchange, a real publish with index
verification, tag and GitHub Release creation, and the merge queue (personal repos don't have
one). 0.13.1 will be the first end-to-end run. If anything fails part-way, the Release
workflow can be re-run on main, and crates already published are skipped.

Known items: three Windows tests are skipped (with a TODO) because they add a SQL login to
an integrated-auth connection string, which is a test bug and not a CI one. The macOS lanes
take about 45 minutes, so the queue timeout should be 120 minutes or more.

Who does what

Me, once you're happy with this PR (repo admin, about 10 minutes):

  1. Merge this PR (rebase or squash, as main allows today).
  2. Create dev from the resulting main.
  3. Settings → Pull Requests: turn on "Allow merge commits" (keep squash and rebase on).
    The rulesets decide which branch may use which method.
  4. Make dev the default branch.
  5. Edit the existing "main" ruleset, which targets the default branch and would otherwise
    silently move to dev:
    • set its target to refs/heads/main;
    • allow only merge commits;
    • require the check release gate;
    • keep deletion and force-push protection.
  6. New ruleset for dev:
    • require a PR, allowing squash and rebase;
    • require the checks ci-ok and qa-ok;
    • require the merge queue (timeout 120 min or more);
    • block force pushes and deletions.
  7. New ruleset for tags v* and tiberius-macros-v*: block updates, deletions and force
    pushes. Creation stays open, because the Release workflow creates the tags.

You (needs a crates.io owner login, about 5 minutes):

  1. Add a Trusted Publisher on crates.io for each crate (Settings → Trusted Publishing →
    Add → GitHub):

    Crate Owner Repository Workflow Environment
    tiberius tiberius-rs tiberius release.yml (blank)
    tiberius-macros tiberius-rs tiberius release.yml (blank)

    tiberius-macros is owned by you alone, so that one has to be you. Once the first release
    has gone out through CI, you can switch both crates to require Trusted Publishing and
    revoke any old tokens.

Nothing else is needed from you: no secrets, no workflow changes. The day-to-day is review,
"Merge when ready", and a dev → main PR when it's time to ship. CONTRIBUTING.md in this
PR describes the flow for contributors. Once #8 is done I'll also delete the stale qa
branch and the sync/* leftovers.

Next

My 0.13.1 PR (the stacked fixes, rebased on current main) would be the first release through
this: into dev via the queue, then a dev → main PR, and 0.13.1 publishes itself.

Closes #454

Thoughts on this?

Replace test.yml, security.yml and pr-code-security.yml with three
workflows:

- ci.yml (CI): the fast tier on every PR push and in the merge queue:
  rustfmt, clippy, lib tests for four feature sets, MSRV 1.88, docs,
  cargo-deny (also weekly), gitleaks, advisory semver-checks and a SQL
  Server 2022 smoke test. The ci-ok job aggregates them into the single
  required check.
- qa.yml (QA): the heavy tier in the merge queue: the Linux SQL Server
  matrix (2017/2019/2022/azure-sql-edge x 7 feature sets), macOS via
  colima, Windows SQL 2019 with integrated auth, and strict
  semver-checks against the latest crates.io release. qa-ok aggregates
  them. It also runs on pull_request with every job skipped so the
  required check reports before a PR enters the queue.
- release.yml (Release): every PR into main is a release from dev,
  merged with a merge commit. On such a PR the release gate requires a
  version above crates.io (lower or invalid versions fail), a CHANGELOG
  heading, a free tag, a published or co-released tiberius-macros
  requirement, an unchanged tiberius-macros package unless it is bumped,
  a head on dev, and a merged tree equal to a dev commit whose qa.yml
  run succeeded. A PR that introduces release.yml (the target tip has
  none) passes once as a bootstrap. On push to main, a bumped version is
  published via crates.io Trusted Publishing, checked against the index
  checksum, then tagged with a GitHub Release. An unbumped push is a
  no-op. A dispatch retries a failed release or dry-runs publishing.

The gate lives in .github/scripts/release_gate.py with offline tests
that CI runs. Strict clippy is on for --features=all. The rustls clippy
lane and the rustdoc warnings stay advisory until the 0.13.1 stack
lands, because upstream main still has lints there. The Windows lane
skips the three special-character password tests: they build a SQL
login string on top of an IntegratedSecurity=true one, so they only run
on Linux and macOS until the test also sets IntegratedSecurity=false.

Also add dependabot for actions, CODEOWNERS for .github/ and
CONTRIBUTING.md, point the README badge at tiberius-rs/tiberius ci.yml,
and update the deny.toml comment.

This branch has not been deployed

No deployments
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.

Publishing to crates.io

1 participant