noyalib-mcp 0.0.13

Model Context Protocol server exposing noyalib's lossless YAML editing to AI agents
Documentation
# SPDX-FileCopyrightText: 2026 Noyalib
# SPDX-License-Identifier: MIT OR Apache-2.0

name: Release

# Satellite multi-channel release. On tag push:
#   validate → artifacts (SLSA L3 + sigstore + SBOM)
#            → cross-verify
#            → github-release
#            → { crates-io, npm-publish, container-publish, mcp-registry }
#
# Uses the same permissions + attestation pattern as the parent
# noyalib. Strict-lockstep versioning (ADR-0005) is enforced by
# the `validate` job — the tag must match Cargo.toml's version AND
# the `noyalib = "=X.Y.Z"` pin.

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:
  # ── Quality gate ────────────────────────────────────────────────────
  validate:
    name: Validate
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          fetch-depth: 0

      - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88
        with:
          toolchain: stable
          components: rustfmt, clippy

      # CACHE-POISONING GUARD: isolated CARGO_TARGET_DIR so a stale
      # PR-build fingerprint can never mask a release regression.
      - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        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"

  # ── Build .crate + SBOM + SLSA L3 provenance + sigstore ─────────────
  artifacts:
    name: Build artifacts
    needs: validate
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
      attestations: write
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
      - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88
        with:
          toolchain: stable
      - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2

      - 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 # v4.1.1
        with:
          subject-path: |
            target/package/*.crate
            SBOM.txt

      - name: Install cosign
        uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
      - 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 # v7.0.1
        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-platform verification ─────────────────────────────────────
  # MCP is stdio-JSON-RPC; the process-lifecycle + IPC surface differs
  # across host OSes (line endings, path separators, signal handling),
  # so the full triad matters here.
  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 # v7.0.0
      - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88
        with:
          toolchain: stable
      - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
        with:
          workspaces: ". -> target-release-verify-${{ matrix.os }}"
          key: release-verify-${{ matrix.os }}
      - run: cargo test --all-features --locked

  # ── GitHub Release ──────────────────────────────────────────────────
  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 # v7.0.0
        with:
          fetch-depth: 0

      - name: Download artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        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

  # ── Publish to crates.io ────────────────────────────────────────────
  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 # v7.0.0
      - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88
        with:
          toolchain: stable
      - name: Publish
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: cargo publish --all-features --locked

  # ── Publish npm wrapper ─────────────────────────────────────────────
  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 # npm Trusted Publishing OIDC + --provenance
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          ref: ${{ github.ref }}

      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        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

  # ── Publish GHCR container image ────────────────────────────────────
  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 # cosign keyless signing
      attestations: write
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - name: Compute version
        id: version
        run: |
          TAG_VERSION="${GITHUB_REF_NAME#v}"
          echo "VERSION=$TAG_VERSION" >> "$GITHUB_OUTPUT"

      - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1

      - name: Log in to GHCR
        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build + push image
        id: build
        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        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 # v4.1.1
        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 # v4.1.2
      - name: Sign container (keyless)
        env:
          COSIGN_YES: "true"
        run: |
          cosign sign ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}

  # ── Register with the MCP Registry ─────────────────────────────────
  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 # OIDC exchange with the MCP Registry
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

      - 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