diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml new file mode 100644 index 0000000..63ab4ea --- /dev/null +++ b/.github/workflows/publish-crate.yml @@ -0,0 +1,141 @@ +name: Publish crate + +on: + workflow_dispatch: + inputs: + version: + description: Exact crate version without a v prefix (for example, 0.2.0) + required: true + type: string + release_ref: + description: Exact 40-character commit SHA to publish + required: true + type: string + +permissions: + contents: write + +concurrency: + group: publish-a3s-runtime-${{ inputs.version }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + RELEASE_REF: ${{ inputs.release_ref }} + RELEASE_VERSION: ${{ inputs.version }} + +jobs: + publish: + name: Publish a3s-runtime + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Validate inputs + run: | + set -euo pipefail + if [[ ! "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Expected a stable SemVer version, got '$RELEASE_VERSION'" + exit 1 + fi + if [[ ! "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Expected an exact 40-character commit SHA" + exit 1 + fi + + - name: Check out exact release commit + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ env.RELEASE_REF }} + + - name: Install Rust 1.85 + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 + with: + toolchain: "1.85.0" + components: rustfmt + + - name: Verify release source + run: | + set -euo pipefail + if [ "$(git rev-parse HEAD)" != "$RELEASE_REF" ]; then + echo "::error::Checkout does not match the requested release commit" + exit 1 + fi + PACKAGE_VERSION="$( + cargo metadata --locked --no-deps --format-version 1 | + jq -r '.packages[] | select(.name == "a3s-runtime") | .version' + )" + if [ "$PACKAGE_VERSION" != "$RELEASE_VERSION" ]; then + echo "::error::Cargo version $PACKAGE_VERSION does not match $RELEASE_VERSION" + exit 1 + fi + cargo fmt --all --check + cargo test --locked --all-targets + cargo publish --locked --dry-run + + - name: Publish and verify crate + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + set -euo pipefail + CRATE_URL="https://crates.io/api/v1/crates/a3s-runtime/$RELEASE_VERSION" + USER_AGENT="A3S-Lab-Runtime-release-publisher/$RELEASE_VERSION (https://github.com/A3S-Lab/Runtime)" + + crate_status() { + curl --retry 5 --retry-all-errors --silent --show-error \ + --location --user-agent "$USER_AGENT" \ + --output /dev/null --write-out '%{http_code}' "$CRATE_URL" + } + + STATUS="$(crate_status)" + case "$STATUS" in + 200) + echo "a3s-runtime@$RELEASE_VERSION already exists; skipping upload" + ;; + 404) + cargo publish --locked + ;; + *) + echo "::error::crates.io returned HTTP $STATUS before publication" + exit 1 + ;; + esac + + for attempt in $(seq 1 18); do + STATUS="$(crate_status)" + if [ "$STATUS" = "200" ]; then + echo "Verified a3s-runtime@$RELEASE_VERSION on crates.io" + exit 0 + fi + if [ "$STATUS" != "404" ]; then + echo "::error::crates.io returned HTTP $STATUS during verification" + exit 1 + fi + if [ "$attempt" -lt 18 ]; then + sleep 10 + fi + done + echo "::error::a3s-runtime@$RELEASE_VERSION did not become visible on crates.io" + exit 1 + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + TAG="v$RELEASE_VERSION" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + TAG_COMMIT="$( + gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq '.sha' + )" + if [ "$TAG_COMMIT" != "$RELEASE_REF" ]; then + echo "::error::$TAG already exists at a different commit" + exit 1 + fi + echo "$TAG release already exists; skipping creation" + else + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$RELEASE_REF" \ + --title "a3s-runtime $RELEASE_VERSION" \ + --generate-notes + fi