crates-publish.yml
In .github/workflows/ · At e15865eCopy the commit ID
Preview
Code · 14.4 KiBname: "Release: crates.io"
# Tag-driven crates.io publish. The signed `v{version}` tag is the single,
# deliberate release trigger — same as release.yml's binaries and
# mcp-release.yml's docs corpus. On a tag, `cargo publish --workspace` pushes
# every publishable library crate to crates.io in dependency order, waiting for
# each to index before the next. mkit-wasm (publish = false, npm-only) is
# skipped automatically.
#
# Why tag-driven, not release-plz: this workspace owns versioning by hand (the
# version bump + root CHANGELOG land in the release-prep PR). release-plz's
# Release-PR model therefore never fires, and once the signed tag exists
# release-plz treats the version as already released (the tag is its "published"
# marker) and skips it — which is exactly what broke the 0.3.0 crates.io publish.
# `cargo publish` ignores tags entirely, so it has no such conflict and needs no
# pre/post ordering against the tag. release-plz is left inert
# (`release_always = false` in rust/release-plz.toml).
on:
push:
tags:
- 'v*.*.*'
# Escape hatch: re-publish from an existing tag after a transient
# crates.io/index failure. NOTE: cargo errors on an already-published crate,
# so a re-run after a PARTIAL publish must `--exclude` the crates that landed
# (edit the publish step, or publish the remainder manually).
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to publish from (e.g. v0.3.0). Must be pushed to origin.'
required: true
type: string
permissions:
contents: read
# Never let two publishes race.
concurrency:
group: crates-publish
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
RUST_VERSION: "1.95.0"
jobs:
publish:
name: cargo publish --workspace
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
# crates.io API token (publish-new + publish-update). Org-level secret;
# the org policy must grant this repo access.
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_PACKAGE_KEY }}
steps:
- name: Validate tag input shape (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::tag '$RELEASE_TAG' is not a vX.Y.Z release tag"
exit 1
fi
# Publish the TAG's tree, not a branch — what's released matches the tag.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
- name: Install Rust ${{ env.RUST_VERSION }}
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: ${{ env.RUST_VERSION }}
- name: Install protoc
uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0
with:
version: '31.0'
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Native deps for the publish-time verification build (keystore backends:
# tpm / yubikey / secret-service) and the semver gate below. yubikey needs
# libpcsclite; tpm needs libtss2.
- name: Install native dependencies
run: sudo apt-get update && sudo apt-get install -y libtss2-dev libudev-dev libpcsclite-dev pkg-config
- name: Guard — tag matches workspace version
working-directory: rust
run: |
WS_VERSION="$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[] | select(.name == "mkit-core") | .version')"
if [[ "v$WS_VERSION" != "$RELEASE_TAG" ]]; then
echo "::error::workspace version 'v$WS_VERSION' (rust/Cargo.toml at this tag) != tag '$RELEASE_TAG' — refusing to publish a mislabeled release"
exit 1
fi
echo "version check ok: v$WS_VERSION"
# The publish-time semver gate. cargo-semver-checks diffs each
# publishable LIBRARY crate's public API against the version currently
# live on crates.io (the implicit baseline) and fails the publish if the
# version about to be released is too small a bump for the API change
# (e.g. an API break shipped under a mere patch bump). It understands 0.x
# semver, so a 0.MINOR bump may break; it only fails on an UNDER-bump.
#
# This workspace defers version bumps to the release-prep PR. At the
# tag the version matches the intended release, so this gate can infer
# its semver class. rust.yml separately enforces the server's 0.6 API
# policy on PRs (main accumulates 0.6 changes) with the 0.5.0 baseline.
#
# No-ops for any crate not yet on crates.io (a brand-new crate's first
# publish has no baseline). Excludes mkit-cli (unstable CLI-internal lib
# surface, intentionally unguarded) and mkit-wasm (npm-only, publish=false).
- name: Install cargo-semver-checks
uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2
with:
tool: cargo-semver-checks
- name: Semver gate — public API vs crates.io
working-directory: rust
run: |
cargo semver-checks \
--all-features \
-p mkit-core \
-p mkit-server \
-p mkit-attest \
-p mkit-keystore \
-p mkit-rpc \
-p mkit-client \
-p mkit-sync \
-p mkit-transport-file \
-p mkit-transport-http \
-p mkit-transport-memory \
-p mkit-transport-s3 \
-p mkit-transport-enc \
-p mkit-transport-ssh \
-p mkit-transport-connect \
-p mkit-git-bridge
cargo semver-checks -p mkit-server --all-features --baseline-version 0.5.0
# Publishes every member without `publish = false` in dependency order,
# waiting for each to appear on the index. Tags are irrelevant to cargo,
# so the existing signed `v{version}` tag causes no conflict. Idempotent
# only for a clean release: cargo errors on an already-published version,
# so re-runs after a partial publish need `--exclude` for what landed.
- name: Publish workspace to crates.io
working-directory: rust
run: cargo publish --workspace --locked
# Publish the mkit-rpc and mkit-repo proto modules — the two modules in the
# repo-root buf.yaml v2 workspace that carry a `name:` (issue #719) — to
# the Buf Schema Registry, on the same signed release tag as the
# crates.io publish above: a 4th decoupled channel alongside
# binaries/npm/docs (see docs/RELEASE.md "What gets published"). External
# signer integrators and RepoService clients then run `buf generate`
# against a pinned BSR ref instead of vendoring this repo or
# hand-writing generated code the way
# contrib/signers/mkit-sign-se's checked-in Generated/*.pb.swift used to.
#
# DORMANT until BUF_TOKEN is provisioned AND the BUF_PUBLISH_ENABLED
# repo/org variable is flipped to 'true' (see docs/RELEASE.md "One-time
# setup" / "BUF_TOKEN") — same gating pattern as release-plz.yml's
# RELEASE_PLZ_ENABLED. Merging this job before that setup is a clean skip,
# not a failure. (The `if:` here checks `vars`, not `secrets`, because
# job-level `if:` cannot read the `secrets` context — only
# `github`/`inputs`/`needs`/`vars` are available there.)
buf-push:
name: buf push (BSR)
runs-on: ubuntu-latest
if: ${{ vars.BUF_PUBLISH_ENABLED == 'true' }}
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
BUF_VERSION: "1.66.0"
steps:
- name: Validate tag input shape (dispatch only)
if: ${{ github.event_name == 'workflow_dispatch' }}
run: |
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::tag '$RELEASE_TAG' is not a vX.Y.Z release tag"
exit 1
fi
# Publish the TAG's tree, same as the crates.io publish job above.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag || github.ref }}
# Same pinned-version raw-binary install as .github/workflows/proto.yml,
# rather than a third-party setup-buf action, so both CI surfaces track
# one BUF_VERSION source of truth pattern.
- name: Install buf
run: |
set -euo pipefail
arch="$(uname -m)"
case "$arch" in
x86_64) buf_arch="x86_64" ;;
aarch64|arm64) buf_arch="aarch64" ;;
*) echo "unsupported arch: $arch" >&2; exit 1 ;;
esac
curl -sSL -o /usr/local/bin/buf \
"https://github.com/bufbuild/buf/releases/download/v${BUF_VERSION}/buf-Linux-${buf_arch}"
chmod +x /usr/local/bin/buf
buf --version
- name: buf registry login
env:
BUF_TOKEN: ${{ secrets.BUF_TOKEN }}
run: echo "$BUF_TOKEN" | buf registry login buf.build --token-stdin
# The repo-root buf.yaml workspace's lint/breaking checks already gate
# every PR (.github/workflows/buf.yml, #731); re-run lint here as a
# last-mile sanity check before anything goes out to the registry.
- name: buf lint (workspace)
run: buf lint .
# buf breaking, one last time, immediately before the actual BSR
# publish — against the BSR's OWN currently-published state, not
# origin/main (issue #791). proto.yml's PR-time gate and Cloud
# Build's codegen-freshness check are both origin/main-relative, so
# they only protect a commit that reached main through the normal
# required-checks path. This repo has demonstrated admin-override
# merges that bypass required checks (PRs #668, #669/#670); an
# override that broke a wire-frozen field, followed by a tag, would
# otherwise reach buf.build/officialunofficial/mkit-rpc or
# -mkit-repo with zero independent verification at push time. This
# step closes that gap regardless of how the tagged commit landed on
# main.
#
# `--against buf.build/<owner>/<module>` (no `:ref`) compares against
# the module's latest commit on its default BSR branch — the same
# semantics as `--against-registry`, but named explicitly so each
# step is self-documenting about which module it's checking.
#
# A module with no prior BSR commit (this job's first-ever run,
# before `--create` below has pushed anything) can't have a breaking
# change against a state that doesn't exist yet; buf reports that as
# "resource ... was not found" rather than a breaking-change
# violation, so that specific message is treated as a pass-through
# rather than a failure. Any other failure (a real breaking change,
# or an actual auth/network problem) fails the job.
- name: buf breaking (BSR) — mkit-rpc
run: |
set -euo pipefail
if output="$(buf breaking rust/crates/mkit-rpc/proto \
--against buf.build/officialunofficial/mkit-rpc 2>&1)"; then
echo "$output"
else
echo "$output"
if grep -q 'was not found' <<<"$output"; then
echo "buf.build/officialunofficial/mkit-rpc has no prior published state — first push, skipping breaking check"
else
echo "::error::buf breaking found an incompatible change vs. the published buf.build/officialunofficial/mkit-rpc module — refusing to publish"
exit 1
fi
fi
- name: buf breaking (BSR) — mkit-repo
run: |
set -euo pipefail
if output="$(buf breaking apps/repo-worker/proto \
--against buf.build/officialunofficial/mkit-repo 2>&1)"; then
echo "$output"
else
echo "$output"
if grep -q 'was not found' <<<"$output"; then
echo "buf.build/officialunofficial/mkit-repo has no prior published state — first push, skipping breaking check"
else
echo "::error::buf breaking found an incompatible change vs. the published buf.build/officialunofficial/mkit-repo module — refusing to publish"
exit 1
fi
fi
# `--exclude-unnamed` pushes only the modules that carry a `name:` in
# the workspace buf.yaml (mkit-rpc, mkit-repo) and skips the unnamed
# `proto` module (mkit.common.v1/mkit.transport.v1) instead of
# erroring on it — that module is deliberately outside #719's BSR
# publishing scope. `--create`/`--create-visibility private` make the
# first-ever push self-service (no manual "create the BSR repository"
# step) and are no-ops once a module already exists. private:
# signer.proto is the security-critical external-signer contract
# (SPEC-EXTERNAL-SIGNER.md) and repo.proto is this repo's internal
# RepoService contract — org-scoped until there's a deliberate
# decision to make either public.
- name: buf push (workspace, named modules only)
run: |
buf push . \
--exclude-unnamed \
--label "${RELEASE_TAG}" \
--create --create-visibility private
# Smoke test (the issue's stated CI-passing bar): buf generate against
# the just-pushed mkit-repo BSR ref — not the local .proto files —
# using the module's own checked-in reference buf.gen.yaml recipe,
# proving the published module is self-contained and consumable by an
# integrator with no mkit checkout of their own. tsc --noEmit --strict
# proves the generated output actually compiles, not just that files
# were written.
- name: buf generate from the pushed mkit-repo ref
run: |
mkdir -p /tmp/bsr-smoke/mkit-repo
buf generate "buf.build/officialunofficial/mkit-repo:${RELEASE_TAG}" \
--template apps/repo-worker/proto/buf.gen.yaml \
-o /tmp/bsr-smoke/mkit-repo
- name: Install Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
- name: Typecheck the generated TypeScript
run: |
cd /tmp/bsr-smoke/mkit-repo
npm init -y >/dev/null
npm install --no-save typescript@5 @bufbuild/protobuf@2 >/dev/null
mapfile -t files < <(find gen/ts -name '*.ts')
npx tsc --noEmit --strict --module nodenext --moduleResolution nodenext --target es2022 \
"${files[@]}"
echo "generated mkit-repo TypeScript typechecks cleanly"