name: Publish to crates.io
on:
workflow_dispatch:
inputs:
dry_run:
description: "Dry run — only verify the package builds, do not publish"
required: false
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
CARGO_NET_OFFLINE: "false"
RUST_BACKTRACE: "1"
jobs:
pre-flight:
name: Verify release artifacts exist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Check that the release workflow ran for this tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}"
# workflow_dispatch from a branch (not a v* tag) → use the
# latest GitHub Release. This lets us re-run publish from
# main after fixing the workflow without re-tagging.
if [[ "$TAG" != v* ]]; then
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')
fi
echo "Release tag: $TAG"
if ! gh release view "$TAG" > /dev/null 2>&1; then
echo "::error::No GitHub release for $TAG — run release.yml first."
exit 1
fi
echo "Release verified: $TAG"
package-check:
name: cargo package (dry run)
needs: pre-flight
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- name: Verify each crate packages cleanly
run: |
set -euo pipefail
for crate in oxios-markdown oxios-mcp oxios-ouroboros oxios-memory \
oxios-calendar oxios-kernel oxios-gateway \
oxios; do
echo "─── Packaging $crate ───"
cargo package -p "$crate" --no-verify --list
done
publish:
name: cargo publish ${{ matrix.crate }}
needs: [pre-flight]
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true'
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 15
strategy:
fail-fast: false
max-parallel: 1
matrix:
crate:
- oxios-markdown
- oxios-mcp
- oxios-ouroboros
- oxios-memory
- oxios-calendar
- oxios-kernel
- oxios-gateway
- oxios
steps:
- uses: actions/checkout@v5
- name: Wait for crates.io index to see the dependency's new version
run: |
CRATE="${{ matrix.crate }}"
# Map each crate to the internal dependency it must wait for
case "$CRATE" in
oxios-markdown|oxios-mcp|oxios-ouroboros|oxios-memory)
WAIT=""; exit 0 ;;
oxios-calendar)
WAIT="oxios-markdown" ;;
oxios-kernel)
WAIT="oxios-calendar" ;;
oxios-gateway)
WAIT="oxios-kernel" ;;
oxios)
WAIT="oxios-gateway" ;;
*)
WAIT=""; exit 0 ;;
esac
# Read the required version from this crate's Cargo.toml.
# oxios (binary) lives at the workspace root; all others under crates/.
CRATE_DIR="$([ "$CRATE" = "oxios" ] && echo "." || echo "crates/$CRATE")"
TARGET=$(grep -E "^${WAIT} = \{ version = " "$CRATE_DIR/Cargo.toml" \
| head -1 | sed -E 's/.*version = "([^"]+)".*/\1/')
if [ -z "$TARGET" ]; then
echo "::error::Could not determine required version of $WAIT from $CRATE_DIR/Cargo.toml"
exit 1
fi
echo "Waiting for $WAIT $TARGET on crates.io..."
for i in $(seq 1 60); do
# Query crates.io API for the max stable version.
# crates.io requires a User-Agent header — bare curl gets 403.
SEEN=$(curl -fsSL -H "User-Agent: oxios-publish-ci/1.0 (github.com/a7garden/oxios)" \
"https://crates.io/api/v1/crates/$WAIT" \
| grep -oE '"max_stable_version":[[:space:]]*"[^"]+"' \
| head -1 | sed -E 's/.*:[[:space:]]*"([^"]+)".*/\1/')
if [ "$SEEN" = "$TARGET" ]; then
echo "$WAIT $TARGET visible on crates.io"
exit 0
fi
echo " waiting for $WAIT $TARGET (saw $SEEN)... ($i/60)"
sleep 10
done
echo "::error::Timeout waiting for $WAIT $TARGET on crates.io"
exit 1
- name: Publish ${{ matrix.crate }}
working-directory: ${{ matrix.crate == 'oxios' && '.' || format('crates/{0}', matrix.crate) }}
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
if [ -z "$CARGO_REGISTRY_TOKEN" ]; then
echo "::error::Neither CARGO_TOKEN nor CARGO_REGISTRY_TOKEN is set as a repository secret."
echo "::error::Create one at https://crates.io/settings/tokens with publish scope."
exit 1
fi
# NOTE: no `--no-verify`. `cargo publish` compiles the packaged
# tarball against its *registry* dependencies (path deps are
# stripped during packaging). This is the gate that catches bugs a
# workspace build cannot — e.g. a cross-crate `include_bytes!` that
# resolves in-tree but escapes the crate root in the published
# tarball. Topological order + the "Wait for dependency" step above
# ensure each crate's internal deps are already visible on
# crates.io when it verifies.
#
# Idempotent: if the exact version is already on crates.io, treat
# it as success so the whole matrix can be safely re-run to publish
# only the crates a prior run missed (e.g. a transient error
# mid-matrix). Without this, re-running would fail-fast on every
# already-published crate and never reach the missing one.
set +e
cargo publish --token "$CARGO_REGISTRY_TOKEN" 2>&1 | tee /tmp/publish.log
status=${PIPESTATUS[0]}
set -e
if [ "$status" -eq 0 ]; then
exit 0
fi
if grep -qiE "already exists on crates.io|already uploaded|already been published" /tmp/publish.log; then
echo "::notice::${{ matrix.crate }} is already published at this version; skipping."
exit 0
fi
exit "$status"