name: Release
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+*']
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run (skip publish steps)'
type: boolean
default: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
jobs:
validate:
name: Validate
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88
with:
toolchain: stable
components: rustfmt, clippy
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 with:
workspaces: ". -> target-release-validate"
key: release-validate
- name: Quality checks
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target-release-validate
run: |
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features --locked
cargo doc --no-deps --all-features --locked
- name: Verify tag matches Cargo.toml + noyalib pin
if: startsWith(github.ref, 'refs/tags/v')
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
exit 1
fi
NOYALIB_PIN=$(grep -E '^noyalib\s*=' Cargo.toml | head -1 | sed 's/.*"=\?\([0-9.]*\)".*/\1/')
if [ "$NOYALIB_PIN" != "$CARGO_VERSION" ]; then
echo "::error::noyalib dep pin ($NOYALIB_PIN) is out of lockstep with noyalib-mcp ($CARGO_VERSION)"
exit 1
fi
echo "Version verified: tag=$TAG_VERSION, crate=$CARGO_VERSION, noyalib pin=$NOYALIB_PIN"
- name: Verify server.json + glama.json version
if: startsWith(github.ref, 'refs/tags/v')
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
SERVER_VERSION=$(python3 -c 'import json; print(json.load(open("server.json"))["version"])')
GLAMA_VERSION=$(python3 -c 'import json; print(json.load(open("glama.json"))["version"])')
for pair in "server.json:$SERVER_VERSION" "glama.json:$GLAMA_VERSION"; do
file="${pair%%:*}"
v="${pair##*:}"
if [ "$v" != "$TAG_VERSION" ]; then
echo "::error::$file version ($v) does not match tag ($TAG_VERSION)"
exit 1
fi
done
echo "Registry manifests aligned: server.json=$SERVER_VERSION, glama.json=$GLAMA_VERSION"
artifacts:
name: Build artifacts
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
- name: Package crate
run: cargo package --all-features --allow-dirty
- name: Generate SBOM
run: |
cargo tree --edges normal --prefix depth --format '{p} {l}' > SBOM.txt
- name: Generate checksums
run: |
set -euo pipefail
cd target/package
: > SHA256SUMS.txt
: > SHA512SUMS.txt
for f in *.crate; do
sha256sum "$f" >> SHA256SUMS.txt
sha512sum "$f" >> SHA512SUMS.txt
done
- name: Attest build provenance (SLSA L3)
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 with:
subject-path: |
target/package/*.crate
SBOM.txt
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 - name: Sign release artefacts (keyless)
env:
COSIGN_YES: "true"
run: |
set -euo pipefail
cd target/package
for f in *.crate; do
cosign sign-blob --bundle "${f}.bundle" "$f"
done
cd -
cosign sign-blob --bundle "SBOM.txt.bundle" SBOM.txt
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: release-artifacts
path: |
target/package/*.crate
target/package/*.bundle
target/package/SHA256SUMS.txt
target/package/SHA512SUMS.txt
SBOM.txt
SBOM.txt.bundle
retention-days: 5
cross-verify:
name: Verify (${{ matrix.os }})
needs: validate
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target-release-verify-${{ matrix.os }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 with:
workspaces: ". -> target-release-verify-${{ matrix.os }}"
key: release-verify-${{ matrix.os }}
- run: cargo test --all-features --locked
github-release:
name: GitHub Release
needs: [artifacts, cross-verify]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
name: release-artifacts
path: artifacts
- name: Generate release notes
run: |
TAG="${GITHUB_REF_NAME}"
PREV_TAG=$(git tag --sort=-version:refname | grep -v "$TAG" | head -1 || echo "")
if [ -n "$PREV_TAG" ]; then
RANGE="${PREV_TAG}..${TAG}"
else
RANGE="${TAG}"
fi
echo "## What's Changed" > RELEASE_NOTES.md
echo "" >> RELEASE_NOTES.md
git log "$RANGE" --pretty=format:"- %s (%h)" --no-merges >> RELEASE_NOTES.md
echo "" >> RELEASE_NOTES.md
echo "" >> RELEASE_NOTES.md
echo "## Checksums" >> RELEASE_NOTES.md
echo '```' >> RELEASE_NOTES.md
cat artifacts/target/package/SHA256SUMS.txt >> RELEASE_NOTES.md
echo '```' >> RELEASE_NOTES.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" \
--title "noyalib-mcp $GITHUB_REF_NAME" \
--notes-file RELEASE_NOTES.md \
artifacts/target/package/*.crate \
artifacts/target/package/*.bundle \
artifacts/target/package/SHA256SUMS.txt \
artifacts/target/package/SHA512SUMS.txt \
artifacts/SBOM.txt \
artifacts/SBOM.txt.bundle
crates-io:
name: Publish to crates.io
needs: [github-release]
if: startsWith(github.ref, 'refs/tags/v') && github.event.inputs.dry_run != 'true'
runs-on: ubuntu-latest
environment: crates-io
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88
with:
toolchain: stable
- name: Publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish --all-features --locked
npm-publish:
name: Publish npm wrapper
needs: [github-release]
if: startsWith(github.ref, 'refs/tags/v') && github.event.inputs.dry_run != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with:
ref: ${{ github.ref }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Sync wrapper version to tag
working-directory: pkg/npm-wrapper
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
npm version --no-git-tag-version --allow-same-version "$TAG_VERSION"
- name: Publish (Trusted Publishing + provenance)
working-directory: pkg/npm-wrapper
run: npm publish --provenance --access public
container-publish:
name: Publish GHCR container image
needs: [github-release]
if: startsWith(github.ref, 'refs/tags/v') && github.event.inputs.dry_run != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write attestations: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- name: Compute version
id: version
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
echo "VERSION=$TAG_VERSION" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
- name: Log in to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build + push image
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 with:
context: .
file: pkg/docker/Dockerfile
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ steps.version.outputs.VERSION }}
ghcr.io/${{ github.repository }}:latest
labels: |
io.modelcontextprotocol.server.name=io.github.sebastienrousseau/noyalib-mcp
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ steps.version.outputs.VERSION }}
- name: Attest container provenance (SLSA L3)
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 - name: Sign container (keyless)
env:
COSIGN_YES: "true"
run: |
cosign sign ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
mcp-registry:
name: Register with MCP Registry
needs: [container-publish]
if: startsWith(github.ref, 'refs/tags/v') && github.event.inputs.dry_run != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- name: Install mcp-publisher
run: |
curl -sfL https://raw.githubusercontent.com/modelcontextprotocol/registry/main/scripts/install-mcp-publisher.sh | bash
- name: Publish to MCP Registry
run: mcp-publisher publish