metaflux-client 0.2.0

Rust SDK for the MetaFlux derivatives L1 — REST + WebSocket, EIP-712 signing, and typed builders for the full signed-action surface (orders, TWAP, margin, vaults, staking, spot/Earn).
Documentation
name: Release

# Publishes both crates to crates.io when a version tag (e.g. v0.1.0) is pushed.
#
# Publish order is FIXED: `metaflux-client` (root) first, then `metaflux`
# (facade). The facade depends on `metaflux-client = "0.1.0"` from crates.io, so
# the root crate must exist on the registry before the facade can resolve.
on:
  push:
    tags:
      - "v*"
  workflow_dispatch:
    inputs:
      tag:
        description: "Version tag to release (e.g. v0.1.0). Must be an EXISTING pushed tag and match the Cargo.toml versions."
        required: true
        type: string

# Least privilege: the job only reads the repo and uploads to crates.io via the
# CARGO_REGISTRY_TOKEN secret. It never writes back to GitHub.
permissions:
  contents: read

# Serialize releases: never let two release runs race the ordered publish.
# Key the group on the resolved release tag (NOT github.ref): on a tag push
# github.ref_name is `v0.1.0`, while on workflow_dispatch github.ref is the
# branch (refs/heads/main). Keying on github.ref would put a tag-triggered run
# and a manual dispatch for the SAME version in different groups, letting them
# race the ordered publish. github.ref_name is `v0.1.0` on a tag push and the
# input on dispatch, so both triggers for one version share a single lock.
# Do NOT cancel an in-flight run — interrupting a mid-upload publish is unsafe.
concurrency:
  group: release-${{ github.event.inputs.tag || github.ref_name }}
  cancel-in-progress: false

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1
  # cargo reads this automatically for `cargo publish`; it is never echoed.
  CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

jobs:
  release:
    name: Publish to crates.io
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.event.inputs.tag || github.ref }}

      - uses: dtolnay/rust-toolchain@stable

      - uses: Swatinem/rust-cache@v2

      # Resolve the release tag from either the push ref or the manual input,
      # strip the leading `v`, and assert it matches BOTH Cargo.toml versions.
      # A mistagged release aborts here, before any upload.
      #
      # On workflow_dispatch we also assert the tag exists as a real git ref
      # BEFORE relying on it: checkout already resolved `inputs.tag` to a commit,
      # so re-verifying refs/tags/<tag> turns a typo into a clear "tag not found"
      # message instead of leaking through as a confusing version mismatch.
      - name: Assert tag matches Cargo.toml versions
        id: guard
        run: |
          set -euo pipefail

          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            TAG="${{ github.event.inputs.tag }}"
            if ! git rev-parse --verify --quiet "refs/tags/${TAG}" >/dev/null; then
              echo "::error::Tag '${TAG}' does not exist as a pushed git tag. Push the tag first, then re-run."
              exit 1
            fi
          else
            TAG="${GITHUB_REF#refs/tags/}"
          fi
          echo "Release tag: ${TAG}"

          case "${TAG}" in
            v*) ;;
            *) echo "::error::Tag '${TAG}' does not start with 'v'." ; exit 1 ;;
          esac
          TAG_VERSION="${TAG#v}"

          # `cargo metadata` is the source of truth; avoids brittle grep over the
          # raw manifests.
          ROOT_VERSION="$(cargo metadata --no-deps --format-version 1 \
            | jq -r '.packages[] | select(.name == "metaflux-client") | .version')"
          FACADE_VERSION="$(cargo metadata --no-deps --format-version 1 \
            | jq -r '.packages[] | select(.name == "metaflux") | .version')"

          echo "tag=${TAG_VERSION}  metaflux-client=${ROOT_VERSION}  metaflux=${FACADE_VERSION}"

          if [ "${TAG_VERSION}" != "${ROOT_VERSION}" ]; then
            echo "::error::Tag version '${TAG_VERSION}' != metaflux-client Cargo.toml version '${ROOT_VERSION}'."
            exit 1
          fi
          if [ "${TAG_VERSION}" != "${FACADE_VERSION}" ]; then
            echo "::error::Tag version '${TAG_VERSION}' != metaflux (facade) Cargo.toml version '${FACADE_VERSION}'."
            exit 1
          fi

          echo "version=${TAG_VERSION}" >> "${GITHUB_OUTPUT}"

      # Pre-publish gate: build + test against the committed lockfile so we never
      # publish something that fails to compile or pass tests. Mirrors CI's
      # all-features + no-default-features coverage.
      - name: Build and test (gate)
        run: |
          set -euo pipefail
          cargo build --locked --all-features
          cargo test --locked --all-features
          cargo test --locked --no-default-features

      # Packaging sanity check for the root crate before any real upload.
      #
      # Deliberately WITHOUT --locked: `cargo publish` verify builds the packaged
      # crate in a temp dir and regenerates a lockfile there, which can differ
      # from the workspace Cargo.lock and make --locked spuriously fail with
      # "the lock file ... needs to be updated". Because this dry-run is a
      # pre-publish GATE, such a spurious failure would abort the entire release
      # before anything is published. The --locked reproducibility guarantee is
      # already covered by the build/test gate above and by the real publish
      # steps below, so dropping it here only affects the sanity check.
      #
      # The facade dry-run is intentionally skipped: it cannot resolve
      # metaflux-client from crates.io until the root crate is published, and the
      # build/test gate already compiled it via the path dependency.
      - name: Package dry-run (metaflux-client)
        run: cargo publish -p metaflux-client --dry-run

      # STEP 1 of 2: publish the root crate first. Idempotent: on a re-run after
      # a partial failure, skip if this version is already on crates.io instead
      # of hard-failing. Modern cargo blocks until the uploaded version is
      # visible in the index before exiting.
      #
      # Idempotency is determined PRIMARILY by querying the crates.io sparse
      # index for the exact name/version (a stable HTTP API), so the re-run
      # guarantee no longer hinges on cargo's human-readable, version-dependent,
      # potentially-localized stderr wording. The stderr grep is kept only as a
      # secondary fallback for a race where the version lands between our check
      # and our upload.
      - name: Publish metaflux-client
        run: |
          set -uo pipefail
          VERSION="${{ steps.guard.outputs.version }}"

          # Primary check: is metaflux-client@VERSION already in the registry?
          # Sparse-index path is the first chars of the (lower-cased) crate name:
          # me/ta/metaflux-client. Each line is one version's JSON blob.
          if curl -fsSL "https://index.crates.io/me/ta/metaflux-client" 2>/dev/null \
               | jq -e --arg v "${VERSION}" 'select(.vers == $v and (.yanked | not))' >/dev/null; then
            echo "metaflux-client ${VERSION} already on crates.io (index check); skipping."
            exit 0
          fi

          out="$(cargo publish --locked -p metaflux-client 2>&1)" && status=0 || status=$?
          # cargo never prints the token; this is plain build/upload logging.
          echo "${out}"

          if [ "${status}" -eq 0 ]; then
            echo "Published metaflux-client ${VERSION}."
            exit 0
          fi
          # Secondary fallback: catch a publish that lost the race to another run.
          if echo "${out}" | grep -qiE "already (exists|uploaded)|crate version .* is already uploaded"; then
            echo "metaflux-client ${VERSION} already on crates.io (stderr fallback); skipping."
            exit 0
          fi
          echo "::error::Publishing metaflux-client failed."
          exit "${status}"

      # STEP 2 of 2: publish the facade. cargo's built-in index wait usually
      # covers propagation of the just-published root crate, but the index can
      # still lag, so wrap this in a bounded retry that retries specifically on
      # dependency-resolution lag. Also idempotent on already-published.
      #
      # Same idempotency strategy as the root crate: a registry index pre-check
      # is the primary skip signal; the stderr grep is only a secondary fallback.
      - name: Publish metaflux (facade, with retry)
        run: |
          set -uo pipefail
          VERSION="${{ steps.guard.outputs.version }}"

          # Primary check: is metaflux@VERSION already in the registry?
          # Sparse-index path for a 7-char name: me/ta/metaflux.
          if curl -fsSL "https://index.crates.io/me/ta/metaflux" 2>/dev/null \
               | jq -e --arg v "${VERSION}" 'select(.vers == $v and (.yanked | not))' >/dev/null; then
            echo "metaflux ${VERSION} already on crates.io (index check); skipping."
            exit 0
          fi

          attempts=5
          delay=20
          for i in $(seq 1 "${attempts}"); do
            echo "Publish attempt ${i}/${attempts} for metaflux ${VERSION}..."
            out="$(cargo publish --locked -p metaflux 2>&1)" && status=0 || status=$?
            echo "${out}"

            if [ "${status}" -eq 0 ]; then
              echo "Published metaflux ${VERSION}."
              exit 0
            fi
            # Secondary fallback: catch a publish that lost the race to another run.
            if echo "${out}" | grep -qiE "already (exists|uploaded)|crate version .* is already uploaded"; then
              echo "metaflux ${VERSION} already on crates.io (stderr fallback); skipping."
              exit 0
            fi
            # Treat an unresolved metaflux-client as index-propagation lag and
            # retry; any other failure is fatal (don't mask real errors). The
            # pattern requires BOTH the metaflux-client name AND a not-found /
            # no-candidate signal on the same line, so a genuine permanent
            # resolution error (e.g. a yanked-only version or a real version-req
            # typo) is NOT misclassified as lag and fails fast.
            if echo "${out}" | grep -qiE "metaflux-client.*(no matching package|failed to select a version|not found in registry|candidate versions found)|((no matching package|failed to select a version|not found in registry|candidate versions found).*metaflux-client)"; then
              if [ "${i}" -lt "${attempts}" ]; then
                echo "Index appears to lag (metaflux-client ${VERSION} not visible yet); retrying in ${delay}s."
                sleep "${delay}"
                continue
              fi
            fi
            echo "::error::Publishing metaflux failed on attempt ${i}."
            exit "${status}"
          done
          echo "::error::Publishing metaflux failed after ${attempts} attempts."
          exit 1