crates-publish.yml

In .github/workflows/

Preview

Code · 14.4 KiB
name: "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"

Files